136 Commits

Author SHA1 Message Date
miguel 897eaf619b correct bug no exp
Co-authored-by: Copilot <copilot@github.com>
2026-05-14 19:12:01 -06:00
miguel d6da4d093b Merge branch 'develop' 2026-05-14 19:09:48 -06:00
miguel 9ccd08acc6 Add contador field to EventoUsuario and update related services for handling user data
Co-authored-by: Copilot <copilot@github.com>
2026-05-13 20:52:53 -06:00
jalvarado 2497488491 Merge pull request 'develop' (#39) from develop into master
Reviewed-on: #39
2026-03-10 15:43:56 -06:00
miguel f4bd054aa1 Refactor respuesta validation in submitRespuestas method for improved clarity 2026-03-09 19:13:26 -06:00
miguel aa2a0f1073 Implementar carga de usuarios desde archivo Excel y validación de lista restringida para eventos 2026-03-08 19:19:30 -06:00
miguel 4550af6e35 Merge branch 'develop' 2025-10-28 14:36:58 -06:00
miguel 2be866e7f9 Merge branch 'master' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api 2025-10-28 14:34:47 -06:00
miguel 6e82e7fdfd mensaje personalizado por tipo de evento 2025-10-28 14:29:50 -06:00
miguel e1ddf84166 mensaje personalizado por tipo de evento 2025-10-28 14:28:47 -06:00
miguel b244c66b1f mensaje personalizado por tipo de evento 2025-10-28 14:28:31 -06:00
evenegas 29ee4e1999 filtado de eventos 2025-10-22 11:36:49 -06:00
evenegas c259649a8d cambio en el correo 2025-10-21 17:44:51 -06:00
evenegas 4615306784 se agregó expiración 2025-10-06 13:50:11 -06:00
santiago 258a03e7b4 Modificaciones 2025-10-06 13:50:11 -06:00
evenegas ba1f39502e qr 2025-10-06 13:50:11 -06:00
evenegas ec76bb4835 merge 2025-10-06 13:50:11 -06:00
evenegas 53fe436e34 se agregó expiración 2025-10-06 13:50:11 -06:00
santiago e81abcb665 Modificaciones 2025-10-06 13:50:00 -06:00
evenegas 12080aa83e qr 2025-10-06 13:49:22 -06:00
evenegas 41d36f540a Se creo un evento por xlsx 2025-10-06 13:48:33 -06:00
evenegas e4ce66684f Se agregó una nueva plantilla 2025-10-06 12:20:24 -06:00
santiago c0b3087d76 Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into develop 2025-10-03 14:29:55 -06:00
santiago 3db1076ee6 Modificaciones 2025-10-03 14:29:14 -06:00
evenegas 8196ea8a31 se agregó expiración 2025-10-03 14:27:18 -06:00
evenegas e7b3540170 se agregó expiración 2025-10-03 14:08:17 -06:00
evenegas 0837bdd17b se agregó log de fecha fin 2025-10-03 12:57:56 -06:00
evenegas e4c2a16c8e se agregó cupo máximo 2025-10-03 12:01:18 -06:00
santiago 00e758f0ef Modificaciones 2025-10-02 16:03:52 -06:00
evenegas d070a4bc8f plantilla 2025-10-02 11:10:35 -06:00
evenegas 2ea0119b87 plantilla 2025-10-02 11:08:51 -06:00
evenegas 3cccee58f6 plantilla 2025-10-02 11:02:44 -06:00
evenegas 55a96d16f6 plantilla 2025-10-02 10:48:26 -06:00
evenegas 2a7a10ad9b plantilla 2025-10-02 10:31:42 -06:00
evenegas 663be0c7c6 Se agregó plantillas 2025-10-01 17:52:36 -06:00
santiago 613a44c355 Se modifico el qr y la carga masiva 2025-10-01 15:37:30 -06:00
santiago 0e8961f792 Modificaciones en la validacion del qr 2025-10-01 14:16:19 -06:00
santiago 6eb53a327a Modificciones sin subir 2025-10-01 12:46:12 -06:00
evenegas be72ee5fa5 qr 2025-10-01 12:43:37 -06:00
evenegas 7b8c19fbd1 qr 2025-10-01 12:23:14 -06:00
evenegas 0745ddc8b4 qr 2025-10-01 12:11:44 -06:00
evenegas f346da673b qr 2025-10-01 12:00:14 -06:00
evenegas 8690c82429 qr 2025-10-01 11:33:17 -06:00
evenegas 1e09c14767 qr 2025-10-01 11:26:49 -06:00
santiago 794d2bcd90 Modificaciones de caducidad de token 2025-10-01 10:30:27 -06:00
santiago 01b5c090ec modificaciones pero sin enviar 2025-10-01 10:17:19 -06:00
evenegas 919d94cb0e Se creo un evento por xlsx 2025-10-01 10:14:27 -06:00
evenegas bbdeab629d Se agregó carga masiva 2025-09-30 18:45:23 -06:00
santiago a876c92dcf Correccion en la validacion del participante evento 2025-09-29 12:14:25 -06:00
santiago 960163fa4a Coreecion de error 2025-09-25 20:57:56 +02:00
santiago 25872c544f Se agregaron las dependencias 2025-09-25 20:13:26 +02:00
santiago 6926e84df3 Correcion de error al tomar asistencia con codigo qr 2025-09-25 19:58:08 +02:00
miguel f1fba6a6cf fix: update email content for event registration confirmation 2025-09-10 13:29:55 -06:00
jorge miguel 92851b240a feat: add endpoint to register participant attendance without token 2025-09-09 18:03:59 -06:00
jorge miguel 6fc315450d feat: add token validation endpoint and logic for QR token verification 2025-09-09 17:43:23 -06:00
jalvarado 574249e09a Merge pull request 'feat: update asistencia registration to use JWT token and modify TypeORM synchronization setting' (#35) from events-division into develop
Reviewed-on: #35
2025-09-09 15:19:00 -06:00
miguel 14ae95c00e feat: update asistencia registration to use JWT token and modify TypeORM synchronization setting 2025-09-09 15:17:59 -06:00
jalvarado d0ae2f14cb Merge pull request 'events-division' (#34) from events-division into develop
Reviewed-on: #34
2025-08-19 10:28:24 -06:00
miguel bc5ddb29c7 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.
2025-08-19 10:27:06 -06:00
miguel 94002a3e50 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. 2025-08-12 20:00:18 -06:00
miguel 0a80d892b3 Add 'recientes' endpoints for cuestionarios and eventos with enhanced data retrieval 2025-08-11 10:22:11 -06:00
miguel 641514b8f8 Add 'recientes' endpoint and implement logic to fetch recent events from the service 2025-08-11 09:46:46 -06:00
miguel 7c06ed0b2c Update attendance validation message in event registration email 2025-06-30 11:10:40 -06:00
jorgemike 1732065ced token correos 2025-06-28 19:07:08 -06:00
miguel 551cc3e3b2 Refactor cuestionario module to support event creation with associated questionnaire, including new DTO and enhanced error handling. 2025-06-23 18:58:20 -06:00
miguel e20a381ba6 Add cupo_maximo field and related validations to cuestionario and evento modules 2025-06-23 12:40:38 -06:00
miguel 8fe2b6cc72 Disable schema synchronization and dropping for database configurations 2025-06-20 10:39:29 -06:00
miguel a64e4ea925 Update email subject to include event name for attendance validation 2025-06-20 10:36:26 -06:00
miguel 74de90eda8 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.
2025-06-19 18:36:29 -06:00
miguel ae0313cd1f drop database in restart 2025-06-18 15:52:37 -06:00
miguel 3d90c51742 cambios 2025-06-18 12:57:52 -06:00
miguel 282f921e43 Refactor code structure for improved readability and maintainability 2025-06-18 12:41:30 -06:00
miguel 6c85847bf4 synhronize extra db 2025-06-18 11:00:44 -06:00
miguel 7d8c9e9ef4 Add Trabajadores module with controller, service, DTOs, and entity; integrate with TypeORM 2025-06-18 10:59:06 -06:00
miguel d188d8bea3 Refactor cuestionario service and event handling; update validation types and remove unused console logs; add new event retrieval methods 2025-06-16 18:24:56 -06:00
miguel 5f302df2a7 Add @nestjs/serve-static dependency and update file paths for banner uploads; remove unused form utility files 2025-06-16 13:49:12 -06:00
miguel 63e1d3183e Add TypeORM integration and seed method for TipoCuestionario 2025-06-16 12:23:49 -06:00
jorgemike d652f9f012 muchos cambios :'v 2025-06-13 22:41:20 -06:00
eithan ff3f799549 Merge pull request 'fecha asistencia' (#28) from develop into master
Reviewed-on: #28
2025-04-25 13:56:47 -06:00
Your Name 0dddfce887 fecha asistencia 2025-04-25 13:56:10 -06:00
eithan e6abd86d38 Merge pull request 'develop' (#27) from develop into master
Reviewed-on: #27
2025-04-25 10:45:19 -06:00
Your Name 210cc139d9 sync y drop false 2025-04-25 09:41:01 -06:00
Your Name 4e472e9e33 csv de respuestas corregido 2025-04-25 09:37:58 -06:00
Your Name 14d191790f login de admin y endpoints de admin validados, falta validar lo de qr 2025-04-21 12:32:45 -06:00
eithan f496c2d8be Merge pull request 'develop' (#26) from develop into master
Reviewed-on: #26
2025-04-09 16:34:53 -06:00
eithan 8a837eda8d Merge branch 'master' into develop 2025-04-09 16:34:44 -06:00
eithan aae1e5ede3 Actualizar 'src/app.module.ts'
false drop y sync
2025-04-09 16:34:11 -06:00
eithan 0c6f5336a9 Actualizar 'src/app.module.ts' 2025-04-09 16:33:17 -06:00
eithan 2a807acb87 Merge pull request 'Actualizar 'src/app.module.ts'' (#25) from develop into master
Reviewed-on: #25
2025-04-09 16:29:09 -06:00
eithan a8645d28e8 Merge branch 'master' into develop 2025-04-09 16:29:04 -06:00
eithan f7f6d8296c Actualizar 'src/app.module.ts'
sync y drop true
2025-04-09 16:28:25 -06:00
eithan f747e146e8 Merge pull request 'Actualizar 'src/cuestionario_respondido/cuestionario_respondido.service.ts'' (#24) from develop into master
Reviewed-on: #24
2025-04-09 09:02:01 -06:00
eithan 10ba9642bd Merge branch 'master' into develop 2025-04-09 09:01:52 -06:00
eithan a2e4f62135 Actualizar 'src/cuestionario_respondido/cuestionario_respondido.service.ts'
nuevo mensaje correo
2025-04-09 09:01:31 -06:00
eithan 283b8df276 Merge pull request 'Actualizar 'src/app.module.ts'' (#23) from develop into master
Reviewed-on: #23
2025-04-07 19:41:09 -06:00
eithan a48f2cb41c Merge branch 'master' into develop 2025-04-07 19:41:03 -06:00
eithan 564cbd4f69 Actualizar 'src/app.module.ts'
synchronize: false,
      dropSchema: false,
2025-04-07 19:40:37 -06:00
eithan ed7cadd6e0 Merge pull request 'develop' (#22) from develop into master
Reviewed-on: #22
2025-04-07 15:25:08 -06:00
eithan d436ca6bbc Merge branch 'master' into develop 2025-04-07 15:24:58 -06:00
eithan 489965681f Merge pull request 'reporte csv' (#21) from eithan into develop
Reviewed-on: #21
2025-04-07 15:07:14 -06:00
Your Name 297e9913bb reporte csv 2025-04-07 15:06:03 -06:00
eithan daca1aa0b0 Merge pull request 'develop' (#20) from develop into master
Reviewed-on: #20
2025-04-07 11:36:13 -06:00
eithan 60127068aa Merge branch 'master' into develop 2025-04-07 11:36:07 -06:00
jalvarado 766a380f98 fix: regex expression 2025-04-07 11:00:21 -06:00
eithan d6d2c1c0d4 Merge pull request 'eithan' (#19) from eithan into develop
Reviewed-on: #19
2025-04-07 09:38:59 -06:00
eithan 9997faadf9 Merge branch 'develop' into eithan 2025-04-07 09:38:53 -06:00
Your Name e3b2d3cb9c otro merge
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-07 09:38:14 -06:00
Your Name c8667dc142 for root async, config module global 2025-04-07 09:38:08 -06:00
eithan 0805cffbab Merge pull request 'eithan' (#18) from eithan into develop
Reviewed-on: #18
2025-04-07 09:11:12 -06:00
eithan 973eb625aa Merge branch 'develop' into eithan 2025-04-07 09:11:07 -06:00
Your Name f9cdf6a593 no se por que merge
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-07 09:10:21 -06:00
Your Name abbbb206cf number en puerto 2025-04-07 09:10:04 -06:00
eithan 91cc3f4e10 Merge pull request 'chage env for alumnos' (#17) from eithan into develop
Reviewed-on: #17
2025-04-07 09:07:42 -06:00
eithan f1ef5c0b3c Merge branch 'develop' into eithan 2025-04-07 09:07:35 -06:00
Your Name 61b2a50c65 chage env for alumnos 2025-04-07 09:06:58 -06:00
eithan 6ce45d5c1a Merge pull request 'develop' (#16) from develop into master
Reviewed-on: #16
2025-04-07 08:43:45 -06:00
eithan 8d1a9d3d53 Merge pull request 'endpoint alumnos' (#15) from eithan into develop
Reviewed-on: #15
2025-04-07 08:42:54 -06:00
Your Name 24e07ea2e5 endpoint alumnos 2025-04-07 08:41:54 -06:00
eithan 0c8981c81d Merge pull request 'eithan' (#14) from eithan into develop
Reviewed-on: #14
2025-04-04 14:43:54 -06:00
eithan fdeb4e557f Merge branch 'develop' into eithan 2025-04-04 14:41:24 -06:00
Your Name 4e992c5b6e buscando conflictos
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-04 14:40:03 -06:00
Your Name e5c6f611f4 docker compose front, modulo de validacion de preguntas, crud de participante, get participantes por evento y evento por participantes 2025-04-04 14:39:04 -06:00
eithan d34fa32672 Merge pull request 'respuestas cerradas y multiples por id, limite de respuestas abiertas a 250' (#13) from eithan into develop
Reviewed-on: #13
2025-04-03 15:00:36 -06:00
eithan 521adac6bf Merge branch 'develop' into eithan 2025-04-03 15:00:29 -06:00
Your Name bc07c307cf respuestas cerradas y multiples por id, limite de respuestas abiertas a 250 2025-04-03 14:59:46 -06:00
eithan c8056c5957 Merge pull request 'swagger server doc' (#12) from eithan into develop
Reviewed-on: #12
2025-04-03 14:34:12 -06:00
Your Name 8208ff0292 swagger server doc 2025-04-03 14:31:53 -06:00
eithan 934b4e4ffe Merge pull request 'generar y enviar qr por correo electronico despues de registrar un formulario' (#11) from eithan into develop
Reviewed-on: #11
2025-04-03 14:11:59 -06:00
eithan 1e58a64cde Merge branch 'develop' into eithan 2025-04-03 14:11:53 -06:00
Your Name 5ba3f0f2a8 generar y enviar qr por correo electronico despues de registrar un formulario 2025-04-03 11:29:46 -06:00
stern f50c9f36c8 Establecer a versión node 22 en script start 2025-04-02 16:09:51 -06:00
eithan fa6a73ea1a Merge pull request 'registrar el evento cuando se crea en el formulario' (#10) from eithan into develop
Reviewed-on: #10
2025-04-02 15:58:02 -06:00
Your Name 8d191fbb23 registrar el evento cuando se crea en el formulario 2025-04-02 15:56:34 -06:00
jalvarado 1e13a5fffd Merge pull request 'qr genereta' (#9) from mike into develop
Reviewed-on: #9
2025-04-02 13:55:49 -06:00
eithan 7c3aba670e Merge pull request 'cuestionario contestado, respuestas guardadas' (#8) from eithan into develop
Reviewed-on: #8
2025-04-02 13:52:46 -06:00
Your Name c4d4fed43d cuestionario contestado, respuestas guardadas 2025-04-02 13:51:04 -06:00
147 changed files with 10148 additions and 2008 deletions
+10
View File
@@ -0,0 +1,10 @@
PORT=4200
db_type=mysql
db_host=mariadb # <-- uso del nombre del servicio, NO IP
db_port=3306
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
+3
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+4 -2
View File
@@ -1,10 +1,12 @@
# Etapa de desarrollo para ejecutar en watch mode
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# Instalar explícitamente las dependencias de autenticación
RUN npm i
COPY . .
@@ -12,7 +14,7 @@ COPY . .
RUN npm run build
# Puerto expuesto
EXPOSE 4200
EXPOSE 4204
# Comando para iniciar en modo desarrollo
CMD ["npm", "run", "start:dev"]
+18 -91
View File
@@ -1,99 +1,26 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
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.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
📌 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).
+16 -1
View File
@@ -11,6 +11,7 @@ services:
MARIADB_DATABASE: formularios_test
volumes:
- mariadb_data:/var/lib/mysql
- ./init-scripts:/docker-entrypoint-initdb.d # Base de datos simulada para escolares
restart: unless-stopped
api:
@@ -24,10 +25,24 @@ services:
- .:/app
- /app/node_modules
ports:
- "4200:4200"
- "4204:4204"
depends_on:
- mariadb
restart: unless-stopped
# front:
# build: ../formularios_front
# container_name: formularios_front
# restart: always
# depends_on:
# - api
# env_file:
# - ../formularios_front/.env
# ports:
# - "4202:3000"
volumes:
mariadb_data:
+24 -35
View File
@@ -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'
},
},
);
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',
// },
};
+22
View File
@@ -0,0 +1,22 @@
-- Crear la base de datos para el registro de alumnos
CREATE DATABASE IF NOT EXISTS datos_feria_registro;
USE datos_feria_registro;
-- Crear la tabla registro_alumno
CREATE TABLE IF NOT EXISTS registro_alumno (
id_ncuenta int(9) unsigned zerofill NOT NULL,
nombre varchar(100) NOT NULL,
apellidos varchar(120) NOT NULL,
carrera varchar(100) NOT NULL,
genero char(1) NOT NULL,
PRIMARY KEY (id_ncuenta)
);
-- Insertar algunos datos de ejemplo
INSERT INTO registro_alumno (id_ncuenta, nombre, apellidos, carrera, genero) VALUES
(000123456, 'Juan', 'Pérez López', 'Ingeniería en Computación', 'M'),
(000789012, 'María', 'González Ramírez', 'Licenciatura en Administración', 'F'),
(000456789, 'Carlos', 'Rodríguez Martínez', 'Ingeniería Civil', 'M'),
(000345678, 'Ana', 'Sánchez Jiménez', 'Medicina', 'F'),
(000901234, 'Pedro', 'Fernández Gómez', 'Arquitectura', 'M'),
(316092940, 'Eithan', 'Fernández Gómez', 'Arquitectura', 'M');
+14
View File
@@ -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');
+1487 -197
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -8,7 +8,7 @@
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
@@ -23,19 +23,30 @@
"@nestjs/common": "^11.0.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.0",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/passport-jwt": "^4.0.1",
"axios": "^1.8.4",
"bcryptjs": "^3.0.2",
"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"
"typeorm": "^0.3.21",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -47,6 +58,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",
+10
View File
@@ -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%"
}
+87 -43
View File
@@ -1,57 +1,101 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } 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 { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
import { LoginAdministradorDto } from './dto/login-administrador.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
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';
@ApiTags('Administradores') // Agrupa los endpoints en Swagger
@AdministradorApiDocumentation.ApiController
@ApiBearerAuth()
@Controller('administrador')
export class AdministradorController {
constructor(private administradorService: AdministradorService) {}
constructor(private administradorService: AdministradorService) {}
@Get()
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiGetAll
getAdministradores(): Promise<Administrador[]> {
return this.administradorService.getAdministradores();
}
@Get()
@ApiOperation({ summary: 'Obtener todos los administradores' })
@ApiResponse({ status: 200, description: 'Lista de administradores obtenida correctamente.' })
getAdministradores(): Promise<Administrador[]> {
return this.administradorService.getAdministradores();
}
@Get(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiGetOne
getAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.getAdminByIdOrFail(id);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener un administrador por ID' })
@ApiParam({ name: 'id', description: 'ID del administrador', example: 1 })
@ApiResponse({ status: 200, description: 'Administrador obtenido correctamente.' })
@ApiResponse({ status: 404, description: 'Administrador no encontrado.' })
getAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.getAdministrador(id)
}
@Post()
@Roles('administrador')
@AdministradorApiDocumentation.ApiCreate
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
return this.administradorService.createAdministrador(newAdministrador);
}
@Post()
@ApiOperation({ summary: 'Registrar un nuevo administrador' })
@ApiBody({
description: 'Datos del administrador a registrar',
schema: {
type: 'object',
properties: {
id_tipo_user: { type: 'integer', example: 1 }
}
}
})
@ApiResponse({ status: 201, description: 'Administrador registrado exitosamente.' })
@ApiResponse({ status: 400, description: 'Datos inválidos.' })
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
return this.administradorService.createAdministrador(newAdministrador)
}
@Post('login')
@AdministradorApiDocumentation.ApiLogin
login(@Body() loginData: LoginAdministradorDto) {
return this.administradorService.login(
loginData.nombre_usuario,
loginData.password,
);
}
@Delete(':id')
deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.deleteAdministrador(id)
}
@Patch(':id/change-password')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiChangePassword
changePassword(
@Param('id', ParseIntPipe) id: number,
@Body() changePasswordDto: ChangePasswordDto,
) {
return this.administradorService.changePassword(
id,
changePasswordDto.currentPassword,
changePasswordDto.newPassword,
);
}
@Patch(':id')
updateAdministrador(@Param('id', ParseIntPipe) id: number, @Body() administrador: UpdateAdministradorDto) {
return this.administradorService.updateAdministrador(id, administrador)
}
@Delete(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiRemove
deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.deleteAdministrador(id);
}
@Patch(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiUpdate
updateAdministrador(
@Param('id', ParseIntPipe) id: number,
@Body() administrador: UpdateAdministradorDto,
) {
return this.administradorService.updateAdministrador(id, administrador);
}
}
-28
View File
@@ -1,28 +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
/*
@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[];
*/
}
+6 -2
View File
@@ -2,10 +2,14 @@ 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({
imports: [TypeOrmModule.forFeature([Administrador])],
imports: [
TypeOrmModule.forFeature([Administrador]),
AuthModule
],
controllers: [AdministradorController],
providers: [AdministradorService],
exports: [AdministradorService],
+195 -56
View File
@@ -1,79 +1,218 @@
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';
import * as bcrypt from 'bcryptjs';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AdministradorService {
constructor(
@InjectRepository(Administrador) private administradorRepository: Repository<Administrador>
) {}
constructor(
@InjectRepository(Administrador)
private administradorRepository: Repository<Administrador>,
private jwtService: JwtService,
) {}
async createAdministrador(administrador: CreateAdministradorDto) {
async createAdministrador(administrador: CreateAdministradorDto) {
// Verificar si ya existe un administrador con el mismo correo
const administradorFound = await this.administradorRepository.findOne({
where: {
correo: administrador.correo,
},
});
//revisar el where
const administradorFound = await this.administradorRepository.findOne({
where: {
id_admnistrador: administrador.id_tipo_user
}
})
if (administradorFound) {
return new HttpException('Administrador already exists', HttpStatus.CONFLICT)
}
//Falta regresar el return
//return this.administradorRepository.save(administradorFound)
if (administradorFound) {
throw new HttpException(
'El correo ya está registrado',
HttpStatus.CONFLICT,
);
}
getAdministradores() {
return this.administradorRepository.find({
relations: ['tipoUser']
})
// Encriptar la contraseña
const hashedPassword = await this.hashPassword(administrador.password);
// Crear y guardar el nuevo administrador
const newAdministrador = this.administradorRepository.create({
nombre_usuario: administrador.nombre_usuario,
correo: administrador.correo,
password: hashedPassword,
tipoUser: { id_tipo_user: administrador.id_tipo_user }, // Asignar el tipo de usuario
});
const savedAdministrador =
await this.administradorRepository.save(newAdministrador);
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
}
async login(nombre_usuario: string, password: string) {
// Buscar administrador por nombre de usuario
const administrador = await this.administradorRepository.findOne({
where: { nombre_usuario },
relations: ['tipoUser'],
});
if (!administrador) {
throw new HttpException(
'Credenciales inválidas',
HttpStatus.UNAUTHORIZED,
);
}
async getAdministrador(id_admnistrador: number) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_admnistrador
},
relations: ['tipoUser']
})
if (!Administrador) {
return new HttpException('User not found', HttpStatus.NOT_FOUND);
}
return administradorFound
// Verificar contraseña
const isPasswordValid = await bcrypt.compare(
password,
administrador.password,
);
if (!isPasswordValid) {
throw new HttpException(
'Credenciales inválidas',
HttpStatus.UNAUTHORIZED,
);
}
async deleteAdministrador(id_admnistrador: number) {
const result = await this.administradorRepository.delete({ id_admnistrador })
// Generar token JWT
const payload = {
sub: administrador.id_administrador,
correo: administrador.correo,
};
if (result.affected === 0) {
return new HttpException('User not found', HttpStatus.NOT_FOUND);
}
return {
token: this.jwtService.sign(payload),
tipo_usuario: administrador.tipoUser.tipo,
};
}
return result
async changePassword(
id_administrador: number,
currentPassword: string,
newPassword: string,
) {
// Buscar administrador
const administrador = await this.administradorRepository.findOne({
where: { id_administrador },
});
if (!administrador) {
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
}
})
if (!administradorFound) {
return new HttpException('User not found', HttpStatus.NOT_FOUND)
}
const updateAdministrador = Object.assign(administradorFound, administrador)
return this.administradorRepository.save(updateAdministrador)
// 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' };
}
private async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt();
return bcrypt.hash(password, salt);
}
getAdministradores() {
return this.administradorRepository.find({
relations: ['tipoUser'],
select: {
nombre_usuario: true,
id_administrador: true,
correo: true,
},
});
}
async getAdminByIdOrFail(id_administrador: number) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
});
if (!administradorFound) {
throw new HttpException(
'Administrador no encontrado',
HttpStatus.NOT_FOUND,
);
}
return administradorFound;
}
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_administrador,
});
if (result.affected === 0) {
throw new HttpException(
'Administrador no encontrado',
HttpStatus.NOT_FOUND,
);
}
return result;
}
async updateAdministrador(
id_administrador: number,
administrador: UpdateAdministradorDto,
) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
});
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;
}
}
@@ -0,0 +1,346 @@
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 = 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',
}),
ApiBody({
description: 'Datos del administrador a registrar',
schema: {
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',
example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador',
},
password: {
type: 'string',
format: 'password',
example: 'password',
description: 'Contraseña del administrador (mínimo 6 caracteres)',
},
id_tipo_user: {
type: 'integer',
example: 1,
description: 'ID del tipo de usuario administrador',
},
},
},
}),
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 },
},
},
}),
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',
}),
ApiBody({
description: 'Credenciales de inicio de sesión',
schema: {
type: 'object',
required: ['correo', 'password'],
properties: {
correo: {
type: 'string',
format: 'email',
example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador',
},
password: {
type: 'string',
format: 'password',
example: 'password',
description: 'Contraseña del administrador',
},
},
},
}),
ApiResponse({
status: 200,
description: 'Inicio de sesión exitoso',
schema: {
type: 'object',
properties: {
access_token: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Token JWT para autenticación',
},
id_administrador: {
type: 'number',
example: 1,
description: 'ID del administrador autenticado',
},
correo: {
type: 'string',
example: 'admin@ejemplo.com',
description: 'Correo del administrador autenticado',
},
tipo_user: {
type: 'number',
example: 1,
description: 'Tipo de usuario del administrador',
},
},
},
}),
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)',
}),
ApiResponse({
status: 200,
description: 'Lista de administradores obtenida correctamente',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipoUser: {
type: 'array',
items: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: {
type: 'string',
example: 'Administrador General',
},
},
},
},
},
},
},
}),
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)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador',
type: 'number',
example: 1,
}),
ApiResponse({
status: 200,
description: 'Administrador obtenido correctamente',
schema: {
type: 'object',
properties: {
id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipoUser: {
type: 'array',
items: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Administrador General' },
},
},
},
},
},
}),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
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)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador a actualizar',
type: 'number',
example: 1,
}),
ApiBody({
description: 'Datos a actualizar del administrador',
schema: {
type: 'object',
properties: {
correo: {
type: 'string',
format: 'email',
example: 'nuevo_admin@ejemplo.com',
description: 'Nuevo correo electrónico del administrador',
},
password: {
type: 'string',
format: 'password',
example: 'NuevaPassword123',
description:
'Nueva contraseña del administrador (mínimo 6 caracteres)',
},
id_tipo_user: {
type: 'integer',
example: 2,
description: 'Nuevo tipo de usuario administrador',
},
},
},
}),
ApiResponse({
status: 200,
description: 'Administrador actualizado correctamente',
schema: {
type: 'object',
properties: {
id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 2 },
},
},
}),
ApiResponse({
status: 400,
description: 'Datos de actualización inválidos',
}),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
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)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador',
type: 'number',
example: 1,
}),
ApiBody({
description: 'Datos para cambio de contraseña',
schema: {
type: 'object',
required: ['currentPassword', 'newPassword'],
properties: {
currentPassword: {
type: 'string',
format: 'password',
example: 'Password123',
description: 'Contraseña actual del administrador',
},
newPassword: {
type: 'string',
format: 'password',
example: 'NuevaPassword123',
description:
'Nueva contraseña del administrador (mínimo 6 caracteres)',
},
},
},
}),
ApiResponse({
status: 200,
description: 'Contraseña actualizada correctamente',
schema: {
type: 'object',
properties: {
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' }),
);
// 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)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador a eliminar',
type: 'number',
example: 1,
}),
ApiResponse({
status: 200,
description: 'Administrador eliminado correctamente',
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' }),
);
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ description: 'Contraseña actual del administrador' })
@IsNotEmpty({ message: 'La contraseña actual es requerida' })
currentPassword: string;
@ApiProperty({ description: 'Nueva contraseña del administrador' })
@MinLength(6, { message: 'La nueva contraseña debe tener al menos 6 caracteres' })
@IsNotEmpty({ message: 'La nueva contraseña es requerida' })
newPassword: string;
}
@@ -1,3 +1,32 @@
import { IsEmail, IsNotEmpty, IsNumber, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateAdministradorDto {
id_tipo_user: number
}
@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',
})
@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;
}
@@ -0,0 +1,18 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginAdministradorDto {
@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;
}
@@ -1,3 +1,19 @@
import { IsEmail, IsNumber, IsOptional, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateAdministradorDto {
id_tipo_user?: number
@ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador', required: false })
@IsEmail({}, { message: 'El correo no es válido' })
@IsOptional()
correo?: string;
@ApiProperty({ example: 'Password123', description: 'Contraseña del administrador', required: false })
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
@IsOptional()
password?: string;
@ApiProperty({ example: 1, description: 'ID del tipo de usuario', required: false })
@IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
@IsOptional()
id_tipo_user?: number;
}
@@ -0,0 +1,30 @@
import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('administrador')
export class Administrador {
@PrimaryGeneratedColumn()
id_administrador: number;
@Column({ unique: true })
nombre_usuario: string;
@Column({ unique: true })
correo: string;
@Column()
password: string;
@Column({ type: 'int' })
id_tipo_user: number;
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador)
@JoinColumn({ name: 'id_tipo_user' })
tipoUser: TipoUser;
}
+93
View File
@@ -0,0 +1,93 @@
import {
Controller,
Get,
NotFoundException,
Param,
Post,
Query,
UnprocessableEntityException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { AlumnosService, UsuarioData } from './alumnos.service';
import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
@ApiTags('alumnos')
@Controller('alumnos')
export class AlumnosController {
constructor(private readonly alumnosService: AlumnosService) {}
@Post('cargar')
@ApiOperation({ summary: 'Cargar archivo Excel con alumnos' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Archivo Excel (.xlsx) con columnas: id_ncuenta, nombre, apellidos, carrera, genero',
},
},
required: ['file'],
},
})
@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) {
if (!file) {
throw new UnprocessableEntityException('No se ha subido ningún archivo.');
}
return this.alumnosService.procesarArchivo(file.path);
}
@Get(':cuenta')
@ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' })
@ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' })
@ApiQuery({
name: 'id_evento',
required: false,
type: Number,
description:
'ID del evento para validar si el alumno está en la lista restringida',
})
@ApiResponse(ALUMNO_RESPONSES[200])
@ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta(
@Param('cuenta') cuenta: string,
@Query('id_evento') idEventoRaw?: string,
): Promise<UsuarioData | null> {
const id_evento =
idEventoRaw !== undefined ? parseInt(idEventoRaw, 10) : undefined;
const alumno = await this.alumnosService.findByCuenta(cuenta, id_evento);
if (!alumno) {
throw new NotFoundException(
`No se encontró alumno con número de cuenta ${cuenta}`,
);
}
return alumno;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlumnoDto {
@ApiProperty({
example: 123456789,
description: 'Número de cuenta del alumno (9 dígitos)',
})
id_ncuenta: number;
@ApiProperty({
example: 'Juan',
description: 'Nombre del alumno',
})
nombre: string;
@ApiProperty({
example: 'Pérez López',
description: 'Apellidos del alumno',
})
apellidos: string;
@ApiProperty({
example: 'Ingeniería en Computación',
description: 'Carrera que cursa el alumno',
})
carrera: string;
@ApiProperty({
example: 'M',
description: 'Género del alumno (M: Masculino, F: Femenino)',
enum: ['M', 'F'],
})
genero: string;
}
export const ALUMNO_RESPONSES = {
200: {
description: 'Datos del alumno obtenidos correctamente',
type: AlumnoDto,
},
404: {
description: 'Alumno no encontrado',
},
500: {
description: 'Error interno del servidor',
},
};
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlumnosController } from './alumnos.controller';
import { AlumnosService } from './alumnos.service';
import { RegistroAlumno } from './entities/registro-alumno.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({
imports: [
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
EventoUsuarioModule,
],
controllers: [AlumnosController],
providers: [AlumnosService],
exports: [AlumnosService],
})
export class AlumnosModule {}
+118
View File
@@ -0,0 +1,118 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity';
import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service';
import * as fs from 'fs';
import * as XLSX from 'xlsx';
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(
@InjectRepository(RegistroAlumno, 'alumnosConnection')
private registroAlumnoRepository: Repository<RegistroAlumno>,
private readonly eventoUsuarioService: EventoUsuarioService,
) {}
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]);
console.log(filePath);
let insertados = 0;
let omitidos = 0;
for (const row of data) {
console.log(row);
const cuentaRaw = row['id_ncuenta'];
const cuenta = Number.isFinite(Number(cuentaRaw))
? Number(cuentaRaw)
: null;
if (!cuenta) {
omitidos++;
continue;
}
const existe = await this.registroAlumnoRepository.findOne({
where: { id_ncuenta: cuenta },
});
if (existe) {
omitidos++;
continue;
}
const nuevo = this.registroAlumnoRepository.create({
id_ncuenta: cuenta,
nombre: (row['nombre'] || '').trim(),
apellidos: (row['apellidos'] || '').trim(),
carrera: (row['carrera'] || '').trim(),
genero: (row['genero'] || '').trim().substring(0, 1).toUpperCase(),
});
await this.registroAlumnoRepository.save(nuevo);
insertados++;
}
fs.unlinkSync(filePath);
return { insertados, omitidos };
}
async findByCuenta(
cuenta: string,
id_evento?: number,
): Promise<UsuarioData | null> {
const alumno = await this.registroAlumnoRepository.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
if (!alumno) {
throw new NotFoundException(
`No se encontró alumno con número de cuenta ${cuenta}`,
);
}
if (id_evento !== undefined) {
const tieneLista =
await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento);
if (tieneLista) {
const enLista = await this.eventoUsuarioService.identificadorEnLista(
id_evento,
alumno.id_ncuenta.toString(),
);
if (!enLista) {
throw new ForbiddenException(
`El alumno con cuenta ${cuenta} no está en la lista de usuarios habilitados para este evento.`,
);
}
}
}
return {
cuenta: alumno.id_ncuenta.toString(),
nombre: alumno.nombre,
apellidos: alumno.apellidos,
carrera: alumno.carrera,
genero: alumno.genero,
};
}
}
@@ -0,0 +1,19 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity('registro_alumno')
export class RegistroAlumno {
@PrimaryColumn({ type: 'int', unsigned: true, zerofill: true, width: 9 })
id_ncuenta: number;
@Column({ type: 'varchar', length: 100 })
nombre: string;
@Column({ type: 'varchar', length: 120 })
apellidos: string;
@Column({ type: 'varchar', length: 100 })
carrera: string;
@Column({ type: 'char', length: 1 })
genero: string;
}
+44 -50
View File
@@ -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,69 +19,58 @@ 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 { TrabajadoresModule } from './trabajadores/trabajadores.module';
import { TipoEventoModule } from './tipo_evento/tipo_evento.module';
import { EventoUsuarioModule } from './evento_usuario/evento_usuario.module';
@Module({
imports: [
ConfigModule.forRoot(),
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: true,
dropSchema: true, // 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
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: createMainDbConfig,
inject: [ConfigService],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
name: 'alumnosConnection',
useFactory: createAlumnosDbConfig,
inject: [ConfigService],
}),
CuestionarioModule,
TipoCuestionarioModule,
CuestionarioSeccionModule,
SeccionModule,
SeccionPreguntaModule,
PreguntaModule,
TipoPreguntaModule,
OpcionModule, PreguntaOpcionModule,
RespuestaParticipanteCerradaModule,
CuestionarioRespondidoModule,
RespuestaParticipanteAbiertaModule,
EventoModule,
TipoUserModule,
ParticipanteModule,
QrModule,
AdministradorModule,
AlumnosModule,
AsistenciaModule,
AuthModule,
CuestionarioModule,
CuestionarioRespondidoModule,
CuestionarioSeccionModule,
EventoModule,
OpcionModule,
ParticipanteEventoModule,
ParticipanteModule,
PreguntaModule,
PreguntaOpcionModule,
RespuestaParticipanteAbiertaModule,
RespuestaParticipanteCerradaModule,
SeccionModule,
SeccionPreguntaModule,
TipoCuestionarioModule,
TipoEventoModule,
TipoPreguntaModule,
TipoUserModule,
ValidacionesModule,
QrModule,
TrabajadoresModule,
EventoUsuarioModule,
],
controllers: [AppController],
+1 -1
View File
@@ -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';
-41
View File
@@ -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;
*/
}
+1 -1
View File
@@ -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])],
+1 -1
View File
@@ -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';
@@ -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;
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
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.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET', 'tu_clave_secreta'),
signOptions: {
expiresIn: '24h',
},
}),
}),
TypeOrmModule.forFeature([Administrador]),
],
providers: [JwtStrategy],
exports: [PassportModule, JwtModule],
})
export class AuthModule {}
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ('administrador' | 'staff')[]) =>
SetMetadata(ROLES_KEY, roles);
+52
View File
@@ -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<boolean> {
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;
}
}
+46
View File
@@ -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<string[]>(
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;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Administrador } from '../administrador/entities/administrador.entity';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(Administrador)
private administradorRepository: Repository<Administrador>,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'tu_clave_secreta',
});
}
async validate(payload: any) {
const { sub: id_administrador } = payload;
const administrador = await this.administradorRepository.findOne({
where: { id_administrador },
select: {
id_administrador: true,
correo: true,
},
});
if (!administrador) {
throw new UnauthorizedException('Token inválido');
}
return administrador;
}
}
@@ -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>(CuestionarioController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+169 -5
View File
@@ -1,9 +1,31 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
ParseIntPipe,
UploadedFile,
UnprocessableEntityException,
UploadedFiles,
BadRequestException,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { CuestionarioService } from './cuestionario.service';
import { CreateCuestionarioDto } 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, 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 fs from 'fs';
import { NotFoundError } from 'rxjs';
import { error } from 'console';
@Controller('cuestionario')
@CuestionarioApiDocumentation.ApiController
@@ -16,18 +38,157 @@ export class CuestionarioController {
return this.cuestionarioService.create(createCuestionarioDto);
}
@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,
}));
}
@Post('carga-masiva')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = './uploads/excel';
// Crear la carpeta si no existe
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: (req, file, cb) => {
// Guardar el archivo con timestamp + nombre original
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const fileExt = extname(file.originalname); // obtener extensión
cb(null, `${uniqueSuffix}${fileExt}`);
},
}),
fileFilter: (req, file, cb) => {
// Solo permitir archivos .xlsx
if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .xlsx'), false);
}
},
}),
)
async cargaMasiva(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No se ha subido ningún archivo');
}
console.log('Archivo guardado en:', file.path);
// Leer el archivo con ExcelJS
// Aquí tu lógica para procesar las hojas del Excel
const cuestionarios = await this.cuestionarioService.cargarEventosDesdeExcel(file.path); // Ejemplo de arreglo de datos procesados
// ...
if (!cuestionarios.data) {
throw error;
}
return {
message: cuestionarios.message,
total: cuestionarios.data.length,
data: cuestionarios,
};
}
@Post('withEvento')
@CuestionarioApiDocumentation.ApiCreateWithEvento
createCuestionarioEvento(
@Body() createCuestionarioDto: CreateEventoWithCuestionarioDto,
) {
return this.cuestionarioService.createCuestionarioEvento(
createCuestionarioDto,
);
}
@Post(':id/banner')
@UseInterceptors(
FileInterceptor('banner', {
storage: diskStorage({
destination: './uploads/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() {
return this.cuestionarioService.findAll();
}
@Get('recientes')
@CuestionarioApiDocumentation.ApiGetAll
findAllRecientes() {
return this.cuestionarioService.findAllRecientes();
}
@Get(':id')
@CuestionarioApiDocumentation.ApiGetOne
findOne(@Param('id') id: string) {
return this.cuestionarioService.findOne(+id);
}
@Get(':id/formulario')
@CuestionarioApiDocumentation.ApiGetFormulario
findFormulario(@Param('id') id: string) {
@@ -36,7 +197,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);
}
+5 -2
View File
@@ -11,6 +11,8 @@ 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/entities/evento.entity';
import { EventoService } from '../evento/evento.service';
@Module({
imports: [
@@ -23,11 +25,12 @@ import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionari
Opcion,
PreguntaOpcion,
TipoPregunta,
TipoCuestionario
TipoCuestionario,
Evento
])
],
controllers: [CuestionarioController],
providers: [CuestionarioService],
providers: [CuestionarioService, EventoService],
exports: [TypeOrmModule, CuestionarioService]
})
export class CuestionarioModule {}
@@ -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>(CuestionarioService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+606 -114
View File
@@ -1,17 +1,32 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
import { Repository, DataSource, In, MoreThan } from 'typeorm';
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';
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';
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import {
TipoPregunta,
TipoPreguntaEnum,
} 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';
import * as ExcelJS from 'exceljs';
import { PLANTILLAS } from './plantillas';
@Injectable()
export class CuestionarioService {
@@ -32,10 +47,197 @@ export class CuestionarioService {
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
@InjectRepository(TipoPregunta)
private tipoPreguntaRepository: Repository<TipoPregunta>,
@InjectRepository(Evento)
private eventoRepository: Repository<Evento>,
private dataSource: DataSource,
private eventoService: EventoService,
) {}
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;
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<number, string> = {
1: 'registro-comunidad-alumnos',
2: 'registro-comunidad-trabajadores',
3: 'registro-general',
4: 'registro-general-externos',
};
let data: any[] = [];
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(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]);
}
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);
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);
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: {
...base.datos,
fecha_inicio,
fecha_fin,
id_tipo_evento,
nombre_form,
descripcion,
cupo_maximo, // Aquí puedes agregar más campos si es necesario
},
};
data.push(await this.createCuestionarioEvento(createDto));
continue; // Saltar al siguiente cuestionario
}
// 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 })) || [];
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),
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
cupo_maximo: Number(qRow.getCell(9).value) || undefined,
secciones,
},
};
// Llamada a tu servicio para guardar en DB
data.push(await this.createCuestionarioEvento(createDto));
}
}
return { message: 'Carga masiva completada', data };
}
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();
@@ -45,85 +247,102 @@ export class CuestionarioService {
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.id_tipo_evento = createCuestionarioDto.id_tipo_evento;
cuestionario.contador_secciones =
createCuestionarioDto.secciones?.length || 0;
cuestionario.editable = true;
cuestionario.id_evento = createCuestionarioDto.id_evento;
cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo;
cuestionario.mensaje_correo = createCuestionarioDto.mensaje_correo;
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];
// 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`);
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;
}
// 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);
}
}
@@ -133,10 +352,195 @@ export class CuestionarioService {
}
await queryRunner.commitTransaction();
return {
success: true,
cuestionario: savedCuestionario,
message: 'Cuestionario creado exitosamente',
id_cuestionario: savedCuestionario.id_cuestionario,
id_evento: savedCuestionario.id_evento,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException(
'Error al crear el cuestionario y evento',
error.message,
);
} finally {
await queryRunner.release();
}
}
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,
) {
const { evento, cuestionario } = createCuestionarioDto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
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) {
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, {
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,
id_tipo_evento: cuestionario.id_tipo_evento,
contador_secciones: cuestionario.secciones?.length || 0,
editable: true,
id_evento: eventoExistente.id_evento, // Asociar el evento creado
cupo_maximo: cuestionario.cupo_maximo,
mensaje_correo: cuestionario.mensaje_correo,
});
const savedCuestionario =
await queryRunner.manager.save(cuestionarioEntity);
console.log(
`Cuestionario creado: ${savedCuestionario.nombre_form}, ID: ${savedCuestionario.id_cuestionario}`,
);
// Crear las secciones del cuestionario
if (
createCuestionarioDto.cuestionario.secciones &&
createCuestionarioDto.cuestionario.secciones.length > 0
) {
for (
let i = 0;
i < createCuestionarioDto.cuestionario.secciones.length;
i++
) {
const seccionDto = createCuestionarioDto.cuestionario.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,
message: 'Cuestionario y evento creados exitosamente',
id_cuestionario: savedCuestionario.id_cuestionario,
id_evento: eventoExistente.id_evento,
};
} catch (error) {
await queryRunner.rollbackTransaction();
@@ -146,104 +550,144 @@ 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<Cuestionario> {
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 }
where: { id_cuestionario: id },
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: 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 un mapeo hardcodeado para tipos de pregunta
const tiposPregunta = {
1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
// 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 = tiposPregunta[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,
},
}));
// Construir objeto de pregunta según el formato requerido
return {
id_seccion_pregunta: sp.id_seccion_pregunta,
@@ -254,17 +698,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,
@@ -273,36 +717,84 @@ 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,
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()
: 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,
secciones: seccionesFormateadas
}
id_evento: cuestionario.id_evento || null,
secciones: seccionesFormateadas,
},
};
}
update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
return this.cuestionarioRepository.update(id, updateCuestionarioDto);
async update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
// Si el DTO incluye el campo "evento" (string), debemos procesarlo aparte
if (updateCuestionarioDto.evento) {
// Buscar o crear evento
let evento = await this.eventoRepository.findOne({
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),
};
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);
}
remove(id: number) {
@@ -1,87 +1,82 @@
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 } from './dto/formulario.schema';
import { FormularioDto } from '../dto/formulario.schema';
import {
CreateCuestionarioDto,
CreateCuestionarioEventoDto,
} from '../dto/create-cuestionario.dto';
import { ejemploCreateEventoWithCuestionario, 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'
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',
type: FormularioDto,
content: {
'application/json': {
schema: { $ref: getSchemaPath(FormularioDto) },
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'
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',
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',
value: feriaSexualidad
}
}
ejemplo_con_evento_nuevo: ejemploCreateEventoWithCuestionario,
},
}),
ApiResponse({
status: 201,
description: 'Cuestionario creado correctamente',
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 +87,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 +124,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 +176,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 +207,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' }),
);
}
}
@@ -0,0 +1,224 @@
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',
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,
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 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,
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_TRABAJADOR,
},
{
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: TiposValidacion.RFC,
},
{
titulo: 'Correo electrónico',
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' },
],
},
],
},
],
},
};
@@ -1,31 +1,116 @@
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',
})
@IsNotEmpty()
@IsString()
nombre_form: string;
@ApiProperty({
description: 'Descripción del formulario',
example: 'Formulario de registro para la feria...',
required: false,
})
@IsOptional()
@IsString()
descripcion?: string;
@IsOptional()
@ApiProperty({
description: 'Fecha de inicio',
example: '2025-03-26T00:00:00',
required: false,
})
@IsDateString()
fecha_inicio?: Date;
fecha_inicio: Date;
@IsOptional()
@ApiProperty({
description: 'Fecha de fin',
example: '2025-03-31T23:59:59',
required: false,
})
@IsDateString()
fecha_fin?: Date;
fecha_fin: Date;
@ApiProperty({
description: 'ID del tipo de cuestionario',
example: 1,
})
@IsNotEmpty()
@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,
required: false,
})
@IsOptional()
@IsNumber()
cupo_maximo?: number;
@ApiProperty({
description: 'Nombre del evento asociado',
example: 'Feria de la Sexualidad',
required: false,
})
@IsOptional()
@IsString()
evento?: string;
@ApiProperty({
description: 'Mensaje personalizado que se incluye en el correo de confirmación de registro',
example: 'Favor de presentar este código QR en la entrada del evento.',
required: false,
})
@IsOptional()
@IsString()
mensaje_correo?: string;
@ApiProperty({
description: 'Secciones del cuestionario',
type: [CreateSeccionDto],
required: false,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateSeccionDto)
secciones?: CreateSeccionDto[];
}
export class CreateCuestionarioEventoDto extends OmitType(
CreateCuestionarioDto,
['id_evento'] as const,
) {}
@@ -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;
}
+204 -84
View File
@@ -1,94 +1,214 @@
import { ApiProperty } from '@nestjs/swagger';
export class OpcionDto {
@ApiProperty({
description: 'Valor de la opción',
example: 'Si'
})
valor: string;
}
export class PreguntaDto {
@ApiProperty({
description: 'Título de la pregunta',
example: '¿Eres parte de la comunidad de la FES Acatlán?'
})
titulo: string;
@ApiProperty({
description: 'Indica si la pregunta es obligatoria',
example: true
})
obligatoria: boolean;
@ApiProperty({
description: 'Tipo de pregunta (Texto, Numero, Multiple, Radio, Abierto, Fecha)',
example: 'Multiple'
})
tipo: string;
@ApiProperty({
description: 'Lista de opciones para preguntas de tipo Multiple o Radio',
type: [OpcionDto],
required: false
})
opciones?: OpcionDto[];
}
export class SeccionDto {
@ApiProperty({
description: 'Título de la sección',
example: 'Información Personal'
})
titulo: string;
@ApiProperty({
description: 'Descripción de la sección',
example: 'Proporciona tus datos personales para poder contactarte.'
})
descripcion?: string;
@ApiProperty({
description: 'Lista de preguntas que conforman la sección',
type: [PreguntaDto]
})
preguntas: PreguntaDto[];
}
// Ejemplo de respuesta de cuestionario completo
export const FormularioFeriaSchema = {
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: 3,
descripcion: 'Información básica',
titulo: 'Datos personales'
},
preguntas: [
{
id_seccion_pregunta: 1000,
posicion: 1,
pregunta: {
id_pregunta: 101,
pregunta: '¿Eres parte de la comunidad de la FES Acatlán?',
contador_opcion: 2,
obligatoria: true,
id_tipo_pregunta: 1,
tipo_pregunta: {
id_tipo: 1,
tipo_pregunta: 'Cerrada'
},
opciones: [
{
id_pregunta_opcion: 50001,
posicion: 1,
id_opcion: 90001,
opcion: {
id_opcion: 90001,
opcion: 'Sí'
}
},
{
id_pregunta_opcion: 50002,
posicion: 2,
id_opcion: 90002,
opcion: {
id_opcion: 90002,
opcion: 'No'
}
}
]
}
},
{
id_seccion_pregunta: 1005,
posicion: 2,
pregunta: {
id_pregunta: 109,
pregunta: 'Correo electrónico',
contador_opcion: 0,
obligatoria: true,
id_tipo_pregunta: 2,
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'correo', // Validación de formato de correo electrónico
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
},
{
id_seccion_pregunta: 1001,
posicion: 3,
pregunta: {
id_pregunta: 102,
pregunta: 'Número de cuenta (solo para estudiantes de la FES Acatlán)',
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'cuenta_alumno', // Validación de formato de número de cuenta
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
}
]
},
{
id_cuestionario_seccion: 2,
posicion: 2,
seccion: {
id_seccion: 101,
contador_pregunta: 2,
descripcion: 'Información sobre tus intereses',
titulo: 'Intereses'
},
preguntas: [
{
id_seccion_pregunta: 1003,
posicion: 1,
pregunta: {
id_pregunta: 106,
pregunta: '¿Qué temas te interesan para la Feria de la Sexualidad?',
contador_opcion: 5,
obligatoria: true,
id_tipo_pregunta: 3,
tipo_pregunta: {
id_tipo: 3,
tipo_pregunta: 'Multiple'
},
opciones: [
{
id_pregunta_opcion: 50010,
posicion: 1,
id_opcion: 90010,
opcion: {
id_opcion: 90010,
opcion: 'Educación sexual'
}
},
{
id_pregunta_opcion: 50011,
posicion: 2,
id_opcion: 90011,
opcion: {
id_opcion: 90011,
opcion: 'Prevención de ITS'
}
},
{
id_pregunta_opcion: 50012,
posicion: 3,
id_opcion: 90012,
opcion: {
id_opcion: 90012,
opcion: 'Salud reproductiva'
}
},
{
id_pregunta_opcion: 50013,
posicion: 4,
id_opcion: 90013,
opcion: {
id_opcion: 90013,
opcion: 'Diversidad sexual y de género'
}
},
{
id_pregunta_opcion: 50014,
posicion: 5,
id_opcion: 90014,
opcion: {
id_opcion: 90014,
opcion: 'Relaciones saludables'
}
}
]
}
},
{
id_seccion_pregunta: 1004,
posicion: 2,
pregunta: {
id_pregunta: 107,
pregunta: '¿Algún otro tema que te gustaría que se abordara?',
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
}
]
}
]
}
};
// Ejemplo de tipo FormularioDto para usar en la API
export class FormularioDto {
@ApiProperty({
description: 'Nombre del formulario',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
description: 'Tipo de cuestionario',
example: {
id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta'
}
})
nombre_form: string;
tipo_cuestionario: any;
@ApiProperty({
description: 'Descripción del formulario',
example: 'La Feria de la Sexualidad es un espacio seguro e informativo...'
description: 'Información completa del cuestionario',
example: FormularioFeriaSchema.cuestionario
})
descripcion?: string;
@ApiProperty({
description: 'Fecha de inicio del formulario',
example: '2025-03-07T00:00:01'
})
fecha_inicio?: string;
@ApiProperty({
description: 'Fecha de fin del formulario',
example: '2025-03-18T23:59:59'
})
fecha_fin?: string;
@ApiProperty({
description: 'ID del tipo de cuestionario',
example: 1
})
id_tipo_cuestionario: number;
@ApiProperty({
description: 'Secciones que conforman el formulario',
type: [SeccionDto]
})
secciones: SeccionDto[];
cuestionario: any;
}
@@ -1,4 +1,15 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCuestionarioDto } from './create-cuestionario.dto';
import { IsNumber, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {}
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {
@ApiProperty({
description: 'ID del evento asociado',
example: 1,
required: false
})
@IsOptional()
@IsNumber()
id_evento?: number;
}
@@ -1,8 +1,17 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.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 } from 'typeorm';
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 {
@@ -12,6 +21,9 @@ export class Cuestionario {
@Column({ type: 'text' })
nombre_form: string;
@Column({ type: 'text', nullable: true })
banner: string;
@Column({ type: 'int' })
contador_secciones: number;
@@ -21,22 +33,56 @@ 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: 'datetime' })
fecha_fin: Date;
@Column({ type: 'int', nullable: true })
id_cuestionario_original: number;
cupo_maximo?: number;
@Column({ type: 'int' })
id_tipo_cuestionario: number;
@ManyToOne(()=> Cuestionario)
cuestionarioOriginal: Cuestionario;
@Column({ type: 'int', nullable: true })
id_tipo_evento: number;
@Column({ type: 'int' })
id_evento: number;
@Column({ type: 'text', nullable: true })
mensaje_correo?: string;
// Relaciones
@ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true })
@JoinColumn({ name: 'id_evento' })
evento?: Evento;
@ManyToOne(() => TipoCuestionario, (tipo) => tipo.cuestionarios, {
eager: true,
})
@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,
)
cuestionarioSeccion: CuestionarioSeccion[];
@OneToMany(
() => ParticipanteEvento,
(participanteEvento) => participanteEvento.cuestionario,
)
inscripciones: ParticipanteEvento[];
@OneToMany(() => CuestionarioRespondido, (cr) => cr.cuestionario)
cuestionariosRespondidos: CuestionarioRespondido[];
}
+165
View File
@@ -0,0 +1,165 @@
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
import {
TipoPreguntaEnum,
} from '../tipo_pregunta/entities/tipo_pregunta.entity';
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: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_TRABAJADOR,
},
{ 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: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.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: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_ALUMNO,
},
{ 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: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Institución de procedencia', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.INSTITUCION },
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.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: 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' },
],
},
],
},
],
},
},
{
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'},
],
},
],
},
],
},
},
];
@@ -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>(CuestionarioRespondidoController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -1,8 +1,13 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, Res } from '@nestjs/common';
import { CuestionarioRespondidoService } from './cuestionario_respondido.service';
import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CuestionarioRespondidoApiDocumentation } from './cuestionario_respondido.documentation';
import { Response } from 'express';
@ApiTags('Cuestionario Respondido')
@Controller('cuestionario-respondido')
export class CuestionarioRespondidoController {
constructor(private readonly cuestionarioRespondidoService: CuestionarioRespondidoService) {}
@@ -12,11 +17,26 @@ export class CuestionarioRespondidoController {
return this.cuestionarioRespondidoService.create(createCuestionarioRespondidoDto);
}
@Post('submit')
@CuestionarioRespondidoApiDocumentation.ApiSubmit
submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) {
return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto);
}
@Get()
findAll() {
return this.cuestionarioRespondidoService.findAll();
}
@Get('/reporte-respuestas/:id')
@CuestionarioRespondidoApiDocumentation.ApiReporteRespuestas
async descargarReporteRespuestas(@Param('id') id: string, @Res() res: Response) {
const reporte = await this.cuestionarioRespondidoService.generarReporteRespuestas(+id);
res.header('Content-Type', 'text/csv');
res.header('Content-Disposition', `attachment; filename="reporte-cuestionario-${id}.csv"`);
return res.send(reporte);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.cuestionarioRespondidoService.findOne(+id);
@@ -0,0 +1,193 @@
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 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,
description: 'Respuestas guardadas correctamente',
schema: {
properties: {
success: { type: 'boolean', example: true },
message: {
type: 'string',
example: 'Respuestas guardadas correctamente',
},
cuestionarioRespondidoId: { type: 'number', example: 1 },
},
},
});
static ApiSubmitResponse400 = ApiResponse({
status: 400,
description: 'Datos inválidos en la solicitud',
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' },
},
},
});
static ApiSubmitResponse404 = ApiResponse({
status: 404,
description: 'Cuestionario o pregunta no encontrada',
schema: {
properties: {
statusCode: { type: 'number', example: 404 },
message: {
type: 'string',
example: 'Cuestionario con ID 999 no encontrado',
},
error: { type: 'string', example: 'Not Found' },
},
},
});
static get ApiReporteRespuestas() {
return applyDecorators(
ApiOperation({
summary: 'Descargar reporte de respuestas de un cuestionario',
description: `
Este endpoint permite descargar un archivo CSV con todas las respuestas de un cuestionario.
El reporte incluye información del participante, evento asociado, y el contenido de las respuestas.
Se generará un archivo CSV con los siguientes campos:
- ID Respuesta
- Fecha Respuesta
- ID Participante
- Correo Participante
- Evento
- Cuestionario
- Pregunta
- Respuesta
`,
}),
ApiResponse({
status: 200,
description:
'Reporte de respuestas descargado exitosamente como archivo CSV',
}),
ApiResponse({
status: 404,
description: 'Cuestionario no encontrado',
}),
);
}
static ApiSubmitRespuestasExamples = {
communityExample: {
summary: 'Ejemplo de respuesta para un miembro de la comunidad',
value: {
id_formulario: 1,
correo: 'estudiante@acatlan.unam.mx',
respuestas: [
{ 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)
],
fecha_envio: '2025-04-02T13:45:00',
},
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',
value: {
id_formulario: 1,
correo: 'externo@ejemplo.com',
respuestas: [
{ id_pregunta: 101, valor: 4 }, // Opción "No" (usando el ID de la opción)
{ id_pregunta: 102, valor: 'externo@ejemplo.com' }, // Correo electrónico
{ id_pregunta: 103, valor: 'Juan Carlos' }, // Nombre(s)
{ id_pregunta: 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
],
fecha_envio: '2025-04-02T13:45:00',
},
description:
'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.',
},
};
}
@@ -5,15 +5,37 @@ 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';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidacionesModule } from '../validaciones/validaciones.module';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({
imports: [
TypeOrmModule.forFeature([CuestionarioRespondido]),
TypeOrmModule.forFeature([
CuestionarioRespondido,
Participante,
Cuestionario,
Pregunta,
RespuestaParticipanteAbierta,
RespuestaParticipanteCerrada,
PreguntaOpcion,
Opcion,
]),
CuestionarioModule,
ParticipanteModule
ParticipanteModule,
ValidacionesModule,
QrModule,
EventoUsuarioModule,
],
controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService],
exports: [TypeOrmModule]
exports: [TypeOrmModule],
})
export class CuestionarioRespondidoModule {}
@@ -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>(CuestionarioRespondidoService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -1,26 +1,909 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } 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/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';
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service';
import { QrTokenService } from '../qr/qr-token.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';
import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service';
import { TiposValidacion } from '../pregunta/entities/pregunta.entity';
@Injectable()
export class CuestionarioRespondidoService {
constructor(
@InjectRepository(CuestionarioRespondido)
private cuestionarioRespondidoRepository: Repository<CuestionarioRespondido>,
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
@InjectRepository(Cuestionario)
private cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(Pregunta)
private preguntaRepository: Repository<Pregunta>,
@InjectRepository(PreguntaOpcion)
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
private dataSource: DataSource,
private validadorRespuestasService: ValidadorRespuestasService,
private cuestionarioService: CuestionarioService,
private qrTokenService: QrTokenService,
private eventoUsuarioService: EventoUsuarioService,
) {}
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
return 'This action adds a new cuestionarioRespondido';
}
findAll() {
return `This action returns all cuestionarioRespondido`;
async submitRespuestas(submitDto: SubmitRespuestasDto) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Verificar que el cuestionario existe
const cuestionario = await this.cuestionarioService.getCuestionarioOrFail(
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}.`,
};
}
}
// 1.2 Validar restricción de lista de usuarios del evento
if (cuestionario.evento?.id_evento) {
const id_evento = cuestionario.evento.id_evento;
const tieneLista =
await this.eventoUsuarioService.eventoTieneListaRestringida(
id_evento,
);
if (tieneLista) {
const preguntaIds = submitDto.respuestas.map((r) => r.id_pregunta);
const identityPreguntas = await this.preguntaRepository.find({
where: [
{
id_pregunta: In(preguntaIds),
validacion: TiposValidacion.CUENTA_ALUMNO,
},
{
id_pregunta: In(preguntaIds),
validacion: TiposValidacion.RFC,
},
],
});
let identificador: string | null = null;
for (const pregunta of identityPreguntas) {
const respuesta = submitDto.respuestas.find(
(r) => r.id_pregunta === pregunta.id_pregunta,
);
if (
respuesta &&
respuesta.valor !== null &&
respuesta.valor !== undefined
) {
const valorStr = String(respuesta.valor).trim();
if (valorStr) {
identificador = valorStr.toUpperCase();
break;
}
}
}
if (!identificador) {
await queryRunner.rollbackTransaction();
return {
success: false,
message:
'Este evento requiere que estés en la lista de usuarios habilitados. No se encontró un número de cuenta o RFC válido en las respuestas enviadas.',
};
}
const enLista = await this.eventoUsuarioService.identificadorEnLista(
id_evento,
identificador,
);
if (!enLista) {
await queryRunner.rollbackTransaction();
return {
success: false,
message: `El usuario con identificador ${identificador} no está en la lista de usuarios habilitados para este evento.`,
};
}
}
}
// 2. Buscar o crear participante
let participante = await this.participanteRepository.findOne({
where: { correo: submitDto.correo },
});
if (!participante) {
participante = new Participante();
participante.correo = submitDto.correo;
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) {
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 token para el QR
qrToken = this.qrTokenService.generateQrToken(
participante.id_participante,
cuestionario.evento.id_evento,
cuestionario.id_cuestionario,
cuestionario.fecha_fin,
);
// Generar código QR con el token
qrBuffer = await QRCode.toBuffer(qrToken);
// 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,
tipoEvento: cuestionario.evento.tipo_evento,
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,
mensajePersonalizado: cuestionario.mensaje_correo,
}),
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: 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.`,
qr_token: qrToken,
qr_buffer: qrBuffer ? qrBuffer.toString('base64') : undefined,
};
}
// 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,
);
// 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'],
});
if (!pregunta) {
throw new NotFoundException(
`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`,
);
}
// Si la pregunta tiene una validación configurada y es una respuesta abierta, validarla
if (pregunta.validacion && typeof respuestaDto.valor === 'string') {
const resultadoValidacion =
this.validadorRespuestasService.validarRespuesta(
respuestaDto.valor,
pregunta.validacion,
);
if (!resultadoValidacion.valido) {
throw new BadRequestException(
`Validación fallida en pregunta ${pregunta.id_pregunta}: ${resultadoValidacion.mensaje}`,
);
}
}
// Determinar tipo de pregunta por ID
const esPreguntaAbierta = pregunta.id_tipo_pregunta === 2;
// O determinar tipo de pregunta por su nombre si existe
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)
// Convertir a string y truncar a 250 caracteres si es necesario
let respuestaTexto = String(respuestaDto.valor);
if (respuestaTexto.length > 250) {
respuestaTexto = respuestaTexto.substring(0, 250);
}
const respuestaAbierta = new RespuestaParticipanteAbierta();
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)
// 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.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}`,
);
}
// Guardar respuesta cerrada
const respuestaCerrada = new RespuestaParticipanteCerrada();
respuestaCerrada.preguntaOpcion = preguntaOpciones[0];
respuestaCerrada.cuestionarioRespondido =
savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaCerrada);
}
}
}
// Variables para el QR y correo electrónico
let datosQR: {
id_participante: number;
id_evento: number;
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 = {
id_participante: participante.id_participante,
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 && 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_cuestionario = ?`,
[participante.id_participante, cuestionario.id_cuestionario],
);
// Si no existe, crear la relación
if (
!participanteEventoExistente ||
participanteEventoExistente.length === 0
) {
await this.dataSource.query(
`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,
);
}
// Generar token para el QR
qrToken = this.qrTokenService.generateQrToken(
participante.id_participante,
cuestionario.evento.id_evento,
cuestionario.id_cuestionario,
cuestionario.fecha_fin,
);
qrBuffer = await QRCode.toBuffer(qrToken);
// Enviar correo con el QR
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: `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,
tipoEvento: cuestionario.evento.tipo_evento,
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,
mensajePersonalizado: cuestionario.mensaje_correo,
}),
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) {
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,
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
if (datosQR) {
response.datos_qr = datosQR;
}
return response;
} catch (error) {
// Solo hacemos rollback si no hemos hecho commit todavía
try {
await queryRunner.rollbackTransaction();
} catch (rollbackError) {
console.error('Error durante rollback:', rollbackError.message);
}
throw error;
} finally {
await queryRunner.release();
}
}
findOne(id: number) {
return `This action returns a #${id} cuestionarioRespondido`;
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',
],
});
// 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(
`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
po.id_pregunta,
p.pregunta,
p.id_tipo_pregunta,
o.id_opcion,
o.opcion
FROM respuesta_participante_cerrada rpc
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
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],
);
// 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,
},
}),
);
// Devolver cuestionario con respuestas formateadas
return {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas,
};
}),
);
return resultados;
}
update(id: number, updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto) {
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',
],
});
if (!cuestionarioRespondido) {
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(
`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
po.id_pregunta,
p.pregunta,
p.id_tipo_pregunta,
o.id_opcion,
o.opcion
FROM respuesta_participante_cerrada rpc
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
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],
);
// 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,
},
}),
);
// Crear el objeto de respuesta con el formato deseado
const resultado = {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas,
};
return resultado;
}
update(
id: number,
updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto,
) {
return `This action updates a #${id} cuestionarioRespondido`;
}
remove(id: number) {
return `This action removes a #${id} cuestionarioRespondido`;
}
/* async generarReporteRespuestas(idCuestionario: number): Promise<string> {
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<string, Map<number, string>>();
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<string> {
// Buscar el cuestionario para validar que existe
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: idCuestionario },
relations: ['evento'],
});
if (!cuestionario) {
throw new NotFoundException(
`Cuestionario con ID ${idCuestionario} no encontrado`,
);
}
// Obtener todas las preguntas del cuestionario para los encabezados
const preguntas = await this.dataSource.query(
`
SELECT
p.id_pregunta,
p.pregunta,
p.id_tipo_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],
);
// Obtener los participantes que han respondido este cuestionario
const participantes = await this.dataSource.query(
`
SELECT DISTINCT
cr.id_cuestionario_respondido,
cr.fecha_respuesta,
p.id_participante,
p.correo AS correo_participante,
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_cuestionario = ?
WHERE cr.id_cuestionario = ?
ORDER BY cr.fecha_respuesta DESC
`,
[
cuestionario.evento ? cuestionario.id_cuestionario : null,
idCuestionario,
],
);
// Obtener todas las respuestas para este cuestionario
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],
);
// Crear un mapa de respuestas por participante y pregunta
const respuestasPorParticipante = new Map();
// Agregar respuestas abiertas al mapa
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) => {
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}`,
);
} else {
respuestasPorParticipante.set(key, respuesta.opcion);
}
});
// Preparar los datos para el CSV
const datosReporte: string[][] = [];
// Agregar encabezados con datos del participante seguidos por las preguntas
const encabezados = [
'ID Participante',
'Correo',
'Fecha Respuesta',
'Asistencia',
];
// Agregar cada pregunta como un encabezado
preguntas.forEach((pregunta) => {
encabezados.push(pregunta.pregunta);
});
datosReporte.push(encabezados);
// Crear una fila por cada participante
participantes.forEach((participante) => {
const fila = [
participante.id_participante.toString(),
participante.correo_participante,
new Date(participante.fecha_respuesta).toLocaleString(),
participante.fecha_asistencia ? 'Sí' : 'No',
];
// Agregar cada respuesta en el orden de las preguntas
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');
return csvContent;
}
}
@@ -0,0 +1,99 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
ValidateIf,
MaxLength,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class RespuestaDto {
@ApiProperty({
description: 'ID de la pregunta respondida',
example: 101,
})
@IsNumber()
id_pregunta: number;
@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)
],
oneOf: [
{
type: 'string',
description: 'Texto para pregunta abierta (máximo 250 caracteres)',
},
{ type: 'number', description: 'ID de opción para pregunta cerrada' },
{
type: 'array',
items: { type: 'number' },
description: 'Array de IDs de opciones para pregunta multiple',
},
],
})
@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 cuestionario a responder',
example: 1,
})
@IsNumber()
id_cuestionario: number;
@ApiProperty({
description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com',
})
@IsString()
@IsNotEmpty()
@MaxLength(250, {
message: 'El correo electrónico no puede exceder 250 caracteres',
})
correo: string;
@ApiProperty({
description: 'Array de respuestas a las preguntas',
type: [RespuestaDto],
examples: [
{
id_pregunta: 101,
valor: 'Texto para pregunta abierta',
},
{
id_pregunta: 102,
valor: 5,
},
{
id_pregunta: 103,
valor: [2, 7, 9],
},
],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => RespuestaDto)
respuestas: RespuestaDto[];
@ApiProperty({
description: 'Fecha y hora del envío',
example: '2025-04-02T13:45:00',
})
@IsDateString()
@IsOptional()
fecha_envio?: string;
}
@@ -3,7 +3,8 @@ 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')
export class CuestionarioRespondido {
@@ -10,7 +10,6 @@ export class CuestionarioSeccion {
@Column({ type: 'tinyint' })
posicion: number;
@ManyToOne(() => Cuestionario, (cuestionario) => cuestionario.cuestionarioSeccion)
cuestionario: Cuestionario;
+80
View File
@@ -0,0 +1,80 @@
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';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
export const createMainDbConfig = async (
configService: ConfigService,
): Promise<TypeOrmModuleOptions> => ({
type: 'mariadb',
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USERNAME'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_DATABASE'),
entities: [
Administrador,
Asistencia,
Cuestionario,
CuestionarioRespondido,
CuestionarioSeccion,
Evento,
Opcion,
Participante,
ParticipanteEvento,
Pregunta,
PreguntaOpcion,
RespuestaParticipanteAbierta,
RespuestaParticipanteCerrada,
Seccion,
SeccionPregunta,
TipoCuestionario,
TipoEvento,
TipoPregunta,
TipoUser,
EventoUsuario,
],
retryAttempts: 5,
retryDelay: 3000,
connectTimeout: 30000,
synchronize: configService.get<string>('SYNCHRONIZE') === 'true',
});
export const createAlumnosDbConfig = async (
configService: ConfigService,
): Promise<TypeOrmModuleOptions> => ({
name: 'alumnosConnection',
type: 'mariadb',
host: configService.get<string>('ALUMNOS_DB_HOST'),
port: configService.get<number>('ALUMNOS_DB_PORT'),
username: configService.get<string>('ALUMNOS_DB_USERNAME'),
password: configService.get<string>('ALUMNOS_DB_PASSWORD'),
database: configService.get<string>('ALUMNOS_DB_DATABASE'),
entities: [RegistroAlumno, RegistroTrabajador],
retryAttempts: 10,
retryDelay: 3000,
connectTimeout: 30000,
synchronize: true,
});
+2
View File
@@ -18,7 +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.
`)
.setVersion('1.0')
.addTag('Administradores')
.addTag('Cuestionarios')
.addTag('Secciones')
.addTag('Preguntas')
+307
View File
@@ -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 `
<div
style="
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 650px;
margin: 0 auto;
padding: 20px;
border-radius: 16px;
"
>
<!-- Header del correo -->
<div style="text-align: center">
<h1>¡Registro Confirmado!</h1>
</div>
<!-- Ticket Container -->
<div
style="
background: white;
border-radius: 12px;
overflow: hidden;
background: #fefefe;
position: relative;
"
>
<!-- Ticket Header -->
<div
style="
background: linear-gradient(90deg, #003d79 0%, #0056b3 100%);
color: white;
padding: 20px;
position: relative;
"
>
<h2 style="margin: 0 0 8px 0; font-size: 24px; font-weight: bold">
🎓 ${nombreEvento || 'Evento Especial'}
</h2>
<p style="margin: 0; opacity: 0.9; font-size: 14px">
TICKET DE ENTRADA • ENTRADA GRATUITA
</p>
</div>
<!-- Ticket Body -->
<div style="padding: 30px 20px">
<div style="display: flex; gap: 30px; align-items: center">
<!-- QR Code Section -->
<div
style="
flex: 1;
display: flex;
justify-content: center;
align-items: center;
"
>
<div
style="
background: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 20px;
display: inline-block;
text-align: center;
"
>
<img
src="cid:qrCode"
alt="Código QR de Entrada"
style="
width: 180px;
height: 180px;
display: block;
margin: 0 auto;
"
/>
<p
style="
margin: 12px 0 0 0;
font-size: 12px;
color: #6c757d;
font-weight: 600;
"
>
ESCANEA PARA REGISTRAR ASISTENCIA
</p>
</div>
</div>
<!-- Ticket Info Section -->
<div style="flex: 1.2">
<div style="margin-bottom: 24px">
<h3
style="
color: #003d79;
margin: 0 0 16px 0;
font-size: 18px;
border-bottom: 2px solid #e9ecef;
padding-bottom: 8px;
"
>
📋 Detalles de tu registro
</h3>
<div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Participante
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${correo}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Formulario
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${nombreForm}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Fecha de Registro
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${fechaRegistro}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
🕒 Horario del Evento
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #003d79;
font-weight: 600;
"
>
${horarioEvento}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ticket Footer -->
<div
style="
background: #f8f9fa;
padding: 16px 20px;
border-top: 2px dashed #dee2e6;
"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div>
<p style="margin: 0; font-size: 12px; color: #6c757d">
<strong>IMPORTANTE:</strong> Presenta este código QR en la mesa
de registro
</p>
<p style="margin: 4px 0 0 0; font-size: 11px; color: #6c757d">
Disponible en formato digital o impreso
</p>
</div>
<div style="text-align: right">
<p
style="
margin: 0;
font-size: 14px;
color: #003d79;
font-weight: bold;
"
>
ENTRADA VÁLIDA
</p>
<p style="margin: 2px 0 0 0; font-size: 11px; color: #28a745">
✓ Verificado
</p>
</div>
</div>
</div>
</div>
<!-- Instrucciones adicionales -->
<div
style="
margin-top: 20px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 16px;
"
>
<h4 style="color: #003d79; margin: 0 0 12px 0; font-size: 16px">
📝 Instrucciones importantes:
</h4>
<ul
style="
margin: 0;
padding-left: 20px;
color: #333;
font-size: 14px;
line-height: 1.6;
"
>
<li>Llega 15 minutos antes del inicio del evento</li>
<li>
Presenta este QR en la mesa de registro para validar tu asistencia
</li>
<li>Tu constancia será otorgada al finalizar el evento</li>
<li>Para dudas, acude al módulo de información durante el evento</li>
</ul>
</div>
<!-- Footer del correo -->
<div style="text-align: center; margin-top: 20px">
<p style="color: rgba(255, 255, 255, 0.8); font-size: 13px; margin: 0">
Este mensaje fue enviado automáticamente. Por favor, no respondas a
este correo.
</p>
</div>
</div>
`;
}
+80
View File
@@ -0,0 +1,80 @@
const messagePorTipoEvento: Record<string, string> = {
'Cultural / Artístico':
'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.',
'Feria / Expo':
'Favor de presentar el código QR en cualquier stand de la feria, para recibir tu constancia de asistencia.',
};
const mensajeDefault =
'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.';
export function getMensajeDefaultPorTipoEvento(tipoEvento: string): string {
return messagePorTipoEvento[tipoEvento] ?? mensajeDefault;
}
export function generarHtmlCorreoAsistencia({
nombreForm,
nombreEvento,
correo,
fechaRegistro,
fechaInicioEvento,
fechaFinEvento,
tipoEvento,
mensajePersonalizado,
}: {
nombreForm: string;
nombreEvento: string;
correo: string;
tipoEvento: string;
fechaRegistro: string;
fechaInicioEvento?: string;
fechaFinEvento?: string;
mensajePersonalizado?: string;
}) {
const horarioEvento =
fechaInicioEvento && fechaFinEvento
? `
<div style="margin: 20px 0; padding: 12px; background-color: #e8f0fe; border-left: 4px solid #003d79;">
<p style="margin: 0; font-size: 15px; color: #003d79;">
<strong>🕒 Horario del evento:</strong><br/>
${fechaInicioEvento} <strong>al</strong> ${fechaFinEvento}
</p>
</div>
`
: '';
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #ccc; border-radius: 8px; background-color: #f9f9f9;">
<h1 style="color: #003d79; border-bottom: 2px solid #003d79; padding-bottom: 10px;">🎓 ¡Gracias por registrarte!</h1>
<p style="font-size: 16px; color: #333;">
Has completado exitosamente el formulario: <strong>${nombreForm}</strong>
</p>
<p style="font-size: 16px; color: #333;">
${mensajePersonalizado ?? getMensajeDefaultPorTipoEvento(tipoEvento)}
</p>
<div style="text-align: center; margin: 20px 0;">
<img src="cid:qrCode" alt="Código QR" style="width: 250px; height: 250px; border: 1px solid #ccc; padding: 8px; background-color: white;" />
</div>
<h3 style="color: #003d79;">📋 Tus datos de registro:</h3>
<ul style="font-size: 15px; color: #333; line-height: 1.6;">
<li><strong>Evento:</strong> ${nombreEvento || 'Evento'}</li>
<li><strong>Correo:</strong> ${correo}</li>
<li><strong>Fecha de registro:</strong> ${fechaRegistro}</li>
</ul>
${horarioEvento}
<p style="margin-top: 30px; font-size: 14px; color: #666;">
Si tienes alguna duda, acude al módulo de información durante el evento.
</p>
<p style="font-size: 13px; color: #999; text-align: center; margin-top: 40px;">
Este mensaje fue enviado automáticamente. Por favor, no respondas a este correo.
</p>
</div>
`;
}
+95
View File
@@ -0,0 +1,95 @@
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 ApiCargarUsuarios = applyDecorators(
ApiOperation({
summary:
'Cargar lista de usuarios (alumnos y/o trabajadores) para un evento desde un archivo Excel',
}),
ApiParam({
name: 'id',
type: Number,
description: 'ID del evento al que se carga la lista',
}),
ApiConsumes('multipart/form-data'),
ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description:
'Archivo Excel (.xlsx) con columna "cuenta" para alumnos y/o columna "rfc" para trabajadores',
},
},
required: ['file'],
},
}),
);
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'],
},
}),
);
}
+57
View File
@@ -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',
},
};
+25 -6
View File
@@ -1,11 +1,30 @@
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
}
@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;
@IsOptional()
@IsString()
banner?: string;
}
+73
View File
@@ -0,0 +1,73 @@
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()
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;
@Expose()
@Type(() => TipoEventoOutputDto)
tipoEvento: TipoEventoOutputDto;
}
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[];
}
+26 -5
View File
@@ -1,6 +1,27 @@
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
}
@IsOptional()
@IsString()
@IsNotEmpty({ message: 'El tipo de evento no puede estar vacío si se proporciona' })
tipo_evento?: string;
@IsString()
@IsOptional()
nombre_evento: string;
@IsString()
@IsOptional()
descripcion_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;
}
+46
View File
@@ -0,0 +1,46 @@
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
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[];
@ManyToOne(() => TipoEvento, (tipo) => tipo.eventos, {
eager: true,
})
@JoinColumn({ name: 'id_tipo_evento' })
tipoEvento: TipoEvento;
}
+149 -24
View File
@@ -1,37 +1,162 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UnprocessableEntityException,
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';
import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service';
@Controller('evento')
@EventoApiDocumentation.ApiController
export class EventoController {
constructor(private eventoService: EventoService) {}
constructor(
private eventoService: EventoService,
private eventoUsuarioService: EventoUsuarioService,
) {}
@Get()
getEventos(): Promise<Evento[]> {
return this.eventoService.getEventos()
@Post()
@EventoApiDocumentation.ApiCreate
createEvento(@Body() newEvento: CreateEventoDto) {
return this.eventoService.createEvento(newEvento);
}
@Get()
getEventos(): Promise<Evento[]> {
return this.eventoService.getEventos();
}
@Get('activos/cuestionarios')
getCuestionariosActivos() {
return this.eventoService.getEventosActivosCuestionarios();
}
@Get('recientes/cuestionarios')
getEventosRecientes() {
return this.eventoService.getEventosRecientesCuestionarios();
}
@Get('activos')
getEventosActivos(): Promise<Evento[]> {
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/usuarios')
@EventoApiDocumentation.ApiCargarUsuarios
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (_, file, callback) => {
const uniqueName = `${Date.now()}${extname(file.originalname)}`;
callback(null, uniqueName);
},
}),
fileFilter: (_, file, callback) => {
const ext = extname(file.originalname).toLowerCase();
if (ext === '.xlsx' || ext === '.xls') {
callback(null, true);
} else {
callback(
new Error('Solo se permiten archivos Excel (.xlsx, .xls)'),
false,
);
}
},
}),
)
async cargarUsuarios(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new UnprocessableEntityException(
'No se ha subido ningún archivo o el archivo no es válido.',
);
}
return this.eventoUsuarioService.cargaUsuariosDesdeXlsx(id, file.path);
}
@Post(':id/banner')
@UseInterceptors(
FileInterceptor('banner', {
storage: diskStorage({
destination: './uploads/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 UnprocessableEntityException(
'No se ha subido ningún archivo o el archivo no es 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);
}
}
-41
View File
@@ -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[];
}
+7 -3
View File
@@ -2,12 +2,16 @@ 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';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({
imports: [TypeOrmModule.forFeature([Evento])],
imports: [
TypeOrmModule.forFeature([Evento]),
EventoUsuarioModule,
],
controllers: [EventoController],
providers: [EventoService],
exports: [EventoService]
exports: [EventoService],
})
export class EventoModule {}
+253 -53
View File
@@ -1,73 +1,273 @@
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';
import { join } from 'path';
import { existsSync, unlinkSync } from 'fs';
import { plainToInstance } from 'class-transformer';
import { EventoConCuestionariosDto } from './dto/outpus.dto';
@Injectable()
export class EventoService {
constructor(
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
) {}
constructor(
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
) {}
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);
getEventos() {
return this.eventoRepository.find({
relations: ['participantes']
})
}
return this.eventoRepository.save(createEvento);
}
async getEvento(id_evento: number) {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento
},
relations: ['participantes']
})
// evento.service.ts
if (!eventoFound)
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
async asociarBanner(id: number, filename: string) {
const evento = await this.getEventoOrFail(id);
return eventoFound
}
async deleteEvento(id_evento: number) {
const result = await this.eventoRepository.delete({ id_evento })
if (result.affected === 0) {
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
// 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: 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}`,
);
}
return result
}
}
async updateEvento(id_evento: number, evento: UpdateEventoDto) {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento
}
})
// Asociar el nuevo banner
evento.banner = filename;
return this.eventoRepository.save(evento);
}
if (!eventoFound)
return new HttpException('Evento not found', HttpStatus.NOT_FOUND)
getEventos() {
return this.eventoRepository.find();
}
const updateEvento = Object.assign(eventoFound, evento)
return this.eventoRepository.save(updateEvento)
async getCuestionariosEvento(id_evento: number) {
const eventos = await this.eventoRepository.findOne({
where: {
id_evento: id_evento,
},
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
});
if (!eventos) {
throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`);
}
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) {
return await this.eventoRepository.findOne({
where: {
id_evento,
},
});
}
async getEventosActivosCuestionarios() {
const eventos = await this.eventoRepository.find({
where: {
fecha_fin: MoreThan(new Date()),
},
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
});
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) => {
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<Evento> {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento,
},
});
if (!eventoFound) {
throw new HttpException(
'El evento buscado no existe',
HttpStatus.NOT_FOUND,
);
}
return eventoFound;
}
async deleteEvento(id_evento: number) {
await this.getEventoOrFail(id_evento);
const result = await this.eventoRepository.delete(id_evento);
if (result.affected === 0) {
throw new HttpException('Evento not found', HttpStatus.NOT_FOUND);
}
return { message: 'Evento eliminado exitosamente' };
}
async updateEvento(id_evento: number, evento: UpdateEventoDto) {
const eventoFound = await this.getEventoOrFail(id_evento);
const updateEvento = Object.assign(eventoFound, evento);
return this.eventoRepository.save(updateEvento);
}
async getEventosRecientesCuestionarios(): Promise<
EventoConCuestionariosDto[]
> {
const ahora = new Date();
const hace30Dias = new Date();
hace30Dias.setDate(ahora.getDate() - 30);
const eventos = await this.eventoRepository.find({
where: {
fecha_fin: MoreThan(hace30Dias),
},
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,
});
}
async getEventosActivos() {
const now = new Date();
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) {
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,
};
}
}
@@ -0,0 +1,37 @@
import { Evento } from 'src/evento/entities/evento.entity';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
export enum TipoUsuarioEnum {
ALUMNO = 'alumno',
TRABAJADOR = 'trabajador',
}
@Entity('evento_usuario')
@Index(['id_evento', 'tipo_usuario', 'identificador'], { unique: true })
export class EventoUsuario {
@PrimaryGeneratedColumn()
id_evento_usuario: number;
@Column()
id_evento: number;
@ManyToOne(() => Evento, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_evento' })
evento: Evento;
@Column({ type: 'enum', enum: TipoUsuarioEnum })
tipo_usuario: TipoUsuarioEnum;
@Column({ type: 'varchar', length: 20 })
identificador: string;
@Column({ type: 'int', nullable: true })
contador: number | null;
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventoUsuario } from './entities/evento_usuario.entity';
import { EventoUsuarioService } from './evento_usuario.service';
import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity';
import { RegistroTrabajador } from '../trabajadores/entities/trabajadore.entity';
import { Evento } from '../evento/entities/evento.entity';
@Module({
imports: [
TypeOrmModule.forFeature([EventoUsuario, Evento]),
TypeOrmModule.forFeature([RegistroAlumno, RegistroTrabajador], 'alumnosConnection'),
],
providers: [EventoUsuarioService],
exports: [EventoUsuarioService],
})
export class EventoUsuarioModule {}
@@ -0,0 +1,222 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
EventoUsuario,
TipoUsuarioEnum,
} from './entities/evento_usuario.entity';
import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity';
import { RegistroTrabajador } from '../trabajadores/entities/trabajadore.entity';
import { Evento } from '../evento/entities/evento.entity';
import * as fs from 'fs';
import * as XLSX from 'xlsx';
@Injectable()
export class EventoUsuarioService {
constructor(
@InjectRepository(EventoUsuario)
private readonly eventoUsuarioRepo: Repository<EventoUsuario>,
@InjectRepository(RegistroAlumno, 'alumnosConnection')
private readonly alumnoRepo: Repository<RegistroAlumno>,
@InjectRepository(RegistroTrabajador, 'alumnosConnection')
private readonly trabajadorRepo: Repository<RegistroTrabajador>,
@InjectRepository(Evento)
private readonly eventoRepo: Repository<Evento>,
) {}
async eventoTieneListaRestringida(id_evento: number): Promise<boolean> {
const count = await this.eventoUsuarioRepo.count({ where: { id_evento } });
return count > 0;
}
async identificadorEnLista(
id_evento: number,
identificador: string,
): Promise<boolean> {
const row = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
identificador: identificador.trim().toUpperCase(),
},
});
return !!row;
}
async bulkInsert(
id_evento: number,
alumnos: { identificador: string; contador: number | null }[],
trabajadores: { identificador: string; contador: number | null }[],
): Promise<{ insertados: number; omitidos: number; errores: string[] }> {
let insertados = 0;
let omitidos = 0;
const errores: string[] = [];
for (const alumno of alumnos) {
try {
const existing = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
tipo_usuario: TipoUsuarioEnum.ALUMNO,
identificador: alumno.identificador.toUpperCase(),
},
});
if (existing) {
if (existing.contador !== alumno.contador) {
await this.eventoUsuarioRepo.update(existing.id_evento_usuario, {
contador: alumno.contador,
});
insertados++;
} else {
omitidos++;
}
} else {
await this.eventoUsuarioRepo.insert({
id_evento,
tipo_usuario: TipoUsuarioEnum.ALUMNO,
identificador: alumno.identificador.toUpperCase(),
contador: alumno.contador,
});
insertados++;
}
} catch {
omitidos++;
errores.push(`Error al insertar cuenta ${alumno.identificador}`);
}
}
for (const trabajador of trabajadores) {
try {
const existing = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
tipo_usuario: TipoUsuarioEnum.TRABAJADOR,
identificador: trabajador.identificador.toUpperCase(),
},
});
if (existing) {
if (existing.contador !== trabajador.contador) {
await this.eventoUsuarioRepo.update(existing.id_evento_usuario, {
contador: trabajador.contador,
});
insertados++;
} else {
omitidos++;
}
} else {
await this.eventoUsuarioRepo.insert({
id_evento,
tipo_usuario: TipoUsuarioEnum.TRABAJADOR,
identificador: trabajador.identificador.toUpperCase(),
contador: trabajador.contador,
});
insertados++;
}
} catch {
omitidos++;
errores.push(`Error al insertar RFC ${trabajador.identificador}`);
}
}
return { insertados, omitidos, errores };
}
async cargaUsuariosDesdeXlsx(
id_evento: number,
filePath: string,
): Promise<{ insertados: number; omitidos: number; errores: string[] }> {
const evento = await this.eventoRepo.findOne({ where: { id_evento } });
if (!evento) {
fs.unlinkSync(filePath);
throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`);
}
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const data: any[] = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
const alumnosValidos: { identificador: string; contador: number | null }[] =
[];
const trabajadoresValidos: {
identificador: string;
contador: number | null;
}[] = [];
let omitidos = 0;
const errores: string[] = [];
for (const row of data) {
const cuentaRaw = row['cuenta'];
const rfcRaw = row['rfc'];
const contadorRaw = row['contador'];
const contador =
contadorRaw !== undefined &&
contadorRaw !== null &&
contadorRaw !== '' &&
!isNaN(Number(contadorRaw))
? Number(contadorRaw)
: null;
if (cuentaRaw !== undefined && cuentaRaw !== null && cuentaRaw !== '') {
const cuenta = String(cuentaRaw).trim();
const alumno = await this.alumnoRepo.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
if (!alumno) {
omitidos++;
errores.push(`Cuenta no encontrada en el sistema: ${cuenta}`);
continue;
}
alumnosValidos.push({
identificador: alumno.id_ncuenta.toString(),
contador,
});
continue;
}
if (rfcRaw !== undefined && rfcRaw !== null && rfcRaw !== '') {
const rfc = String(rfcRaw).trim().toUpperCase();
const trabajador = await this.trabajadorRepo.findOne({
where: { rfc },
});
if (!trabajador) {
omitidos++;
errores.push(`RFC no encontrado en el sistema: ${rfc}`);
continue;
}
trabajadoresValidos.push({ identificador: trabajador.rfc, contador });
continue;
}
omitidos++;
}
fs.unlinkSync(filePath);
const resultado = await this.bulkInsert(
id_evento,
alumnosValidos,
trabajadoresValidos,
);
return {
insertados: resultado.insertados,
omitidos: omitidos + resultado.omitidos,
errores: [...errores, ...resultado.errores],
};
}
async getUsuariosEvento(
id_evento: number,
): Promise<{ alumnos: string[]; trabajadores: string[] }> {
const rows = await this.eventoUsuarioRepo.find({ where: { id_evento } });
return {
alumnos: rows
.filter((r) => r.tipo_usuario === TipoUsuarioEnum.ALUMNO)
.map((r) => r.identificador),
trabajadores: rows
.filter((r) => r.tipo_usuario === TipoUsuarioEnum.TRABAJADOR)
.map((r) => r.identificador),
};
}
}
+35 -34
View File
@@ -1,23 +1,28 @@
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';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(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
})
}),
);
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
index: false,
prefix: '/',
});
// CORS configuration
app.enableCors({
origin: '*',
@@ -25,34 +30,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<number>('API_PORT') || 4200);
console.log('\nAPI en ejecución en http://localhost:4200');
console.log('Swagger disponible en http://localhost:4200/api-docs');
}
bootstrap();
+3 -5
View File
@@ -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[];
}
}
@@ -1,7 +1,13 @@
import { IsEmail } from "class-validator";
import { IsEmail, IsNotEmpty, IsNumber } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class CreateParticipanteDto {
@IsEmail()
correo: string
id_tipo_user: number
@ApiProperty({
description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com',
required: true
})
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
correo: string;
}
@@ -1,3 +1,22 @@
import { IsEmail, IsOptional, IsNumber } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class UpdateParticipanteDto {
correo: string
@ApiProperty({
description: 'Nuevo correo electrónico del participante',
example: 'nuevo_correo@ejemplo.com',
required: false
})
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsOptional()
correo?: string;
@ApiProperty({
description: 'Nuevo ID del tipo de usuario',
example: 2,
required: false
})
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
@IsOptional()
id_tipo_user?: number;
}
@@ -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[];
}
+11 -29
View File
@@ -1,60 +1,42 @@
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';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
import { ParticipanteApiDocumentation } from './participante.documentation';
@ApiTags('Participantes') // Agrupa los endpoints en Swagger
@ParticipanteApiDocumentation.ApiController
@Controller('participante')
export class ParticipanteController {
constructor(private participanteService: ParticipanteService) {}
@ApiOperation({ summary: 'Obtener todos los participantes' })
@ApiResponse({ status: 200, description: 'Lista de participantes obtenida correctamente.' })
@ParticipanteApiDocumentation.ApiGetAll
@Get()
getParticipantes(): Promise<Participante[]> {
return this.participanteService.getParticipantes()
return this.participanteService.getParticipantes();
}
@ParticipanteApiDocumentation.ApiGetOne
@Get(':id')
@ApiOperation({ summary: 'Obtener un participante por ID' })
@ApiParam({ name: 'id', description: 'ID del participante', example: 1 })
@ApiResponse({ status: 200, description: 'Participante obtenido correctamente.' })
@ApiResponse({ status: 404, description: 'Participante no encontrado.' })
getParticipante(@Param('id', ParseIntPipe) id: number) {
return this.participanteService.getParticipante(id);
}
@ParticipanteApiDocumentation.ApiCreate
@Post()
@ApiOperation({ summary: 'Registrar un nuevo participante' })
@ApiBody({
description: 'Datos del participante a registrar',
schema: {
type: 'object',
properties: {
correo: { type: 'string', example: 'user@example.com' },
id_tipo_user: { type: 'integer', example: 2 }
}
}
})
@ApiResponse({ status: 201, description: 'Participante registrado exitosamente.' })
@ApiResponse({ status: 400, description: 'Datos inválidos.' })
createParticipante(@Body() newParticipante: CreateParticipanteDto) {
return this.participanteService.createParticipante(newParticipante);
}
@ParticipanteApiDocumentation.ApiRemove
@Delete(':id')
deleteParticipante(@Param('id', ParseIntPipe) id: number) {
return this.participanteService.deleteParticipante(id)
return this.participanteService.deleteParticipante(id);
}
@ParticipanteApiDocumentation.ApiUpdate
@Patch(':id')
updateParticipante(@Param('correo') id: number, @Body() participante: UpdateParticipanteDto) {
updateParticipante(@Param('id', ParseIntPipe) id: number, @Body() participante: UpdateParticipanteDto) {
return this.participanteService.updateParticipante(id, participante);
}
}
@@ -0,0 +1,211 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
export class ParticipanteApiDocumentation {
// Decorador para toda la clase del controlador
static ApiController = ApiTags('Participantes');
// Documentación para crear un participante
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Registrar un nuevo participante',
description: 'Crea un nuevo registro de participante en el sistema'
}),
ApiBody({
description: 'Datos del participante a registrar',
schema: {
type: 'object',
required: ['correo', 'id_tipo_user'],
properties: {
correo: {
type: 'string',
format: 'email',
example: 'usuario@ejemplo.com',
description: 'Correo electrónico del participante'
},
id_tipo_user: {
type: 'integer',
example: 1,
description: 'ID del tipo de usuario'
}
}
}
}),
ApiResponse({
status: 201,
description: 'Participante registrado exitosamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 400, description: 'Datos del participante inválidos' }),
ApiResponse({ status: 409, description: 'El participante ya existe' })
);
// Documentación para obtener todos los participantes
static ApiGetAll = applyDecorators(
ApiOperation({
summary: 'Obtener todos los participantes',
description: 'Retorna una lista de todos los participantes registrados'
}),
ApiResponse({
status: 200,
description: 'Lista de participantes obtenida correctamente',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipo_user: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Estudiante' }
}
},
participanteEventos: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number', example: 1 },
id_participante: { type: 'number', example: 1 },
id_evento: { type: 'number', example: 1 },
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
estatus: { type: 'boolean', example: true }
}
}
}
}
}
}
})
);
// Documentación para obtener un participante por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener un participante por ID',
description: 'Retorna los datos de un participante específico según su ID'
}),
ApiParam({
name: 'id',
description: 'ID del participante',
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Participante obtenido correctamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipo_user: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Estudiante' }
}
},
participanteEventos: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number', example: 1 },
id_participante: { type: 'number', example: 1 },
id_evento: { type: 'number', example: 1 },
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
estatus: { type: 'boolean', example: true }
}
}
}
}
}
}),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
// Documentación para actualizar un participante
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar datos de un participante',
description: 'Actualiza la información de un participante existente'
}),
ApiParam({
name: 'id',
description: 'ID del participante a actualizar',
type: 'number',
example: 1
}),
ApiBody({
description: 'Datos a actualizar del participante',
schema: {
type: 'object',
properties: {
correo: {
type: 'string',
format: 'email',
example: 'nuevo_correo@ejemplo.com',
description: 'Nuevo correo electrónico del participante'
},
id_tipo_user: {
type: 'integer',
example: 2,
description: 'Nuevo tipo de usuario'
}
}
}
}),
ApiResponse({
status: 200,
description: 'Participante actualizado correctamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'nuevo_correo@ejemplo.com' },
id_tipo_user: { type: 'number', example: 2 }
}
}
}),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
// Documentación para eliminar un participante
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar un participante',
description: 'Elimina permanentemente un participante por su ID'
}),
ApiParam({
name: 'id',
description: 'ID del participante a eliminar',
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Participante eliminado correctamente',
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
}
-49
View File
@@ -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[];
}
+1 -1
View File
@@ -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])],
+155 -49
View File
@@ -1,74 +1,180 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Participante } from './participante.entity';
import { Participante } from './entities/participante.entity';
import { Repository } from 'typeorm';
import { CreateParticipanteDto } from './dto/create-participante.dto';
//import { UpdateAdminDto } from 'src/admin/dto/update.admin.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto';
@Injectable()
export class ParticipanteService {
constructor(
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
) {}
constructor(
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
) {}
/**
* Crea un nuevo participante
* @param participante Datos del participante a crear
* @returns El participante creado
* @throws ConflictException si ya existe un participante con el mismo correo
*/
async createParticipante(
participante: CreateParticipanteDto,
): Promise<Participante> {
// Verificar si ya existe un participante con el mismo correo
const participanteFound = await this.participanteRepository.findOne({
where: {
correo: participante.correo,
},
});
async createParticipante(participante: CreateParticipanteDto) {
const participanteFound = await this.participanteRepository.findOne({
where: {
correo: participante.correo
}
})
if (participanteFound)
return new HttpException('Participante already exists', HttpStatus.CONFLICT)
return this.participanteRepository.save(participante)
if (participanteFound) {
throw new ConflictException(
`Ya existe un participante con el correo ${participante.correo}`,
);
}
getParticipantes() {
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<Participante[]> {
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<Participante> {
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`,
);
}
async getParticipante(id_participante: number) {
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante
},
relations: ['tipo_user', 'participanteEventos']
})
return participanteFound;
}
if (!participanteFound)
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
return participanteFound;
async getParticipanteByCorreo(correo: string): Promise<Participante | null> {
const participanteFound = await this.participanteRepository.findOne({
where: {
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
return participanteFound;
}
async getParticipanteByCorreoOrFail(correo: string): Promise<Participante> {
const participanteFound = await this.participanteRepository.findOne({
where: {
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
if (!participanteFound) {
throw new NotFoundException(
`Participante con correo ${correo} no encontrado`,
);
}
async deleteParticipante(id_participante: number) {
const result = await this.participanteRepository.delete({ id_participante })
return participanteFound;
}
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
/**
* 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 result;
if (result.affected === 0) {
throw new NotFoundException(
`Participante con ID ${id_participante} no encontrado`,
);
}
async updateParticipante(id_participante: number, participante: UpdateParticipanteDto) {
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) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
}
/**
* Actualiza los datos de un participante
* @param id_participante ID del participante a actualizar
* @param participante Datos actualizados del participante
* @returns El participante actualizado
* @throws NotFoundException si no se encuentra el participante
* @throws BadRequestException si los datos son inválidos
*/
async updateParticipante(
id_participante: number,
participante: UpdateParticipanteDto,
): Promise<Participante> {
// Verificar si el participante existe
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante,
},
});
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);
}
}
@@ -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;
}
@@ -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;
}
@@ -1,36 +1,355 @@
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 { RegistrarAsistenciaQrDto } from './dto/registrar-asistencia-qr.dto';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiResponse,
ApiBody,
} 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,
);
}
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento()
}
@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,
);
}
@Get(':id')
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.getParticipanteEvento(id)
}
@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,
);
}
@Post()
createParticipanteEvento(@Body() newParticipanteEvento: CreateParticipanteEventoDto) {
return this.participanteEventoService.createParticipanteEvento(newParticipanteEvento)
}
@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);
}
@Delete(':id')
deleteParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.deleteParticipanteEvento(id)
}
@ApiOperation({
summary: 'Obtener todos los registros de participante-evento',
})
@ApiResponse({
status: 200,
description:
'Lista de todos los registros participante-evento con sus relaciones',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
},
},
},
})
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento();
}
@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: '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',
})
@ApiBody({
description: 'Token JWT generado por el código QR',
schema: {
type: 'object',
properties: {
token: { type: 'string' },
},
example: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
})
@ApiResponse({
status: 200,
description: 'Asistencia registrada correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Post('asistencia')
registrarAsistencia(@Body() body: { token: string }) {
return this.participanteEventoService.registrarAsistenciaConToken(
body.token,
);
}
@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',
})
@ApiBody({
description: 'Token JWT generado por el código QR',
schema: {
type: 'object',
properties: {
token: { type: 'string' },
},
example: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
})
@ApiResponse({
status: 200,
description: 'Token válido, información decodificada retornada',
})
@ApiResponse({ status: 400, description: 'Token inválido o expirado' })
@Post('decode-token')
decodeToken(@Body() body: { token: string }) {
return this.participanteEventoService.validToken(body.token);
}
@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({
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,
);
}
}
@@ -1,38 +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
/*
@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;
}
@@ -2,13 +2,22 @@ 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';
import { QrModule } from 'src/qr/qr.module';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
@Module({
imports: [TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante])],
controllers: [ParticipanteEventoController],
imports: [
TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante, Cuestionario, EventoUsuario, RespuestaParticipanteAbierta]),
CuestionarioModule,
QrModule,
],
controllers: [ParticipanteEventoController],
providers: [ParticipanteEventoService],
exports: [ParticipanteEventoService],
})
@@ -1,81 +1,335 @@
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';
import { QrTokenService } from 'src/qr/qr-token.service';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
@Injectable()
export class ParticipanteEventoService {
constructor(
@InjectRepository(ParticipanteEvento)
private participanteEventoRepository: Repository<ParticipanteEvento>,
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
@InjectRepository(Cuestionario)
private cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(EventoUsuario)
private eventoUsuarioRepository: Repository<EventoUsuario>,
@InjectRepository(RespuestaParticipanteAbierta)
private respuestaAbiertaRepository: Repository<RespuestaParticipanteAbierta>,
private readonly cuestionarioService: CuestionarioService,
private readonly qrTokenService: QrTokenService,
) {}
constructor(
@InjectRepository(ParticipanteEvento) private participanteEventoRepository: Repository<ParticipanteEvento>,
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
) {}
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({
return participante_eventoFound;
}
async getParticipanteEventoEspecifico(
id_participante: number,
id_evento: number,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_cuestionario: id_evento,
},
relations: ['participante'],
});
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
return participante_eventoFound;
}
/**
* Obtiene todos los participantes de un evento específico
* @param id_evento ID del evento
* @returns Lista de registros participante-evento con información del participante
*/
async getParticipantesPorCuestionario(id_cuestionario: number) {
const cuestionario =
await this.cuestionarioService.getCuestionarioOrFail(id_cuestionario);
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,
);
}
// Obtener todos los registros participante-evento para este participante
const eventosParticipante = await this.participanteEventoRepository.find({
where: { id_participante },
relations: ['evento'],
});
return eventosParticipante;
}
async deleteParticipanteEvento(id_participante: number) {
const result = await this.participanteEventoRepository.delete({
id_participante,
});
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
return result;
}
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,
},
});
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
return this.participanteEventoRepository.save(participante_evento);
}
async registrarAsistenciaSinToken(
id_participante: number,
id_cuestionario: number,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
cuestionario: { id_cuestionario },
},
});
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
participante_eventoFound.fecha_asistencia = new Date();
participante_eventoFound.asistio = true;
const resultado =
await this.participanteEventoRepository.save(participante_eventoFound);
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario },
});
let contador: number | null | undefined = undefined;
if (cuestionario?.id_evento) {
const tieneListaRestringida =
(await this.eventoUsuarioRepository.count({
where: { id_evento: cuestionario.id_evento },
})) > 0;
if (tieneListaRestringida) {
const respuesta =
(await this.respuestaAbiertaRepository.findOne({
where: {
id_participante
cuestionarioRespondido: {
participante: { id_participante },
cuestionario: { id_cuestionario },
},
pregunta: { validacion: TiposValidacion.CUENTA_ALUMNO },
},
relations: ['evento', 'participante']
})
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
return participante_eventoFound
}
async deleteParticipanteEvento(id_participante: number) {
const result = await this.participanteEventoRepository.delete({ id_participante })
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
return result
}
async updateParticipanteEvento(id_participante: number, id_evento: number, participante_evento: UpdateParticipanteEventoDto) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
relations: ['pregunta', 'cuestionarioRespondido'],
})) ??
(await this.respuestaAbiertaRepository.findOne({
where: {
id_participante,
id_evento
}
})
cuestionarioRespondido: {
participante: { id_participante },
cuestionario: { id_cuestionario },
},
pregunta: { validacion: TiposValidacion.RFC },
},
relations: ['pregunta', 'cuestionarioRespondido'],
}));
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
if (respuesta) {
const identificador = respuesta.respuesta.trim().toUpperCase();
const eventoUsuario = await this.eventoUsuarioRepository.findOne({
where: { id_evento: cuestionario.id_evento, identificador },
});
contador = eventoUsuario?.contador ?? null;
}
return this.participanteEventoRepository.save(participante_evento)
}
}
return { ...resultado, contador };
}
/**
* 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 = await this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) {
return{
success: false,
message:
'El token ya caduco',
data: {}
}
}
const { id_participante, id_evento, id_cuestionario, contador } = 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: false,
message:
'El participante ya había registrado su asistencia previamente',
data: {
participante: participante.correo,
fecha_asistencia_previa: participanteEvento.fecha_asistencia,
fecha_actual: new Date(),
contador,
},
};
}
// 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,
contador,
},
};
}
async validToken(qrToken: string) {
// Validar el token QR
const tokenData = this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) {
return null;
}
return tokenData
}
}
+72 -6
View File
@@ -1,33 +1,99 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
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 class OpcionDto {
@ApiProperty({
description: 'Valor de la opción',
example: 'Sí'
})
@IsNotEmpty()
@IsString()
valor: string;
}
export class CreatePreguntaDto {
@ApiProperty({
description: 'Título o texto de la pregunta',
example: '¿Eres parte de la comunidad de la FES Acatlán?'
})
@IsNotEmpty()
@IsString()
titulo: string;
@ApiProperty({
description: 'Indica si la pregunta es obligatoria',
example: true,
default: false
})
@IsOptional()
@IsBoolean()
obligatoria?: boolean;
@IsNotEmpty()
@IsString()
tipo: string;
@ApiProperty({
description: 'Límite de caracteres para respuestas de texto (solo aplica a preguntas tipo Abierta)',
example: 250,
required: false
})
@IsOptional()
@IsNumber()
limite?: number;
@ApiProperty({
description: 'Tipo de pregunta',
enum: Object.values(TipoPreguntaEnum),
enumName: 'TipoPreguntaEnum',
examples: {
'cerrada': {
summary: 'Pregunta de opción única',
value: TipoPreguntaEnum.Cerrada
},
'abierta': {
summary: 'Pregunta de texto libre',
value: TipoPreguntaEnum.AbiertaParrafo
},
'multiple': {
summary: 'Pregunta de selección múltiple',
value: TipoPreguntaEnum.Multiple
}
}
})
@IsNotEmpty()
@IsEnum(TipoPreguntaEnum, {
message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple'
})
tipo: TipoPreguntaEnum;
@ApiProperty({
description: 'Contador de opciones (se calcula automáticamente)',
required: false
})
@IsOptional()
@IsNumber()
contador_opcion?: number;
@ApiProperty({
description: 'Tipo de validación para la respuesta',
required: false,
examples: ['cuenta_alumno', 'correo', 'telefono', 'nombre', 'entero', 'decimal'],
example: 'correo'
})
@IsOptional()
@IsNumber()
id_opcion_dependiente?: number;
@IsString()
validacion?: TiposValidacion;
@ApiProperty({
description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple',
type: [OpcionDto],
required: false,
examples: [
{ valor: 'Sí' },
{ valor: 'No' }
]
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
+38 -8
View File
@@ -1,8 +1,33 @@
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',
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',
RFC = 'rfc',
}
@Entity()
export class Pregunta {
@@ -18,19 +43,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;
@OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta)
@OneToMany(
() => SeccionPregunta,
(seccionPregunta) => seccionPregunta.pregunta,
)
seccionPreguntas: SeccionPregunta[];
@OneToMany(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.pregunta)
@OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.pregunta)
opciones: PreguntaOpcion[];
}
}
-20
View File
@@ -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>(PreguntaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+109 -50
View File
@@ -1,5 +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 'src/tipo_pregunta/entities/tipo_pregunta.entity';
export class PreguntaApiDocumentation {
// Decoradores para toda la clase del controlador
@@ -9,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',
@@ -19,23 +26,69 @@ export class PreguntaApiDocumentation {
properties: {
titulo: { type: 'string', example: '¿Eres parte de la comunidad?' },
obligatoria: { type: 'boolean', example: true },
tipo: { type: 'string', example: 'Multiple' },
tipo: {
type: 'string',
enum: Object.values(TipoPreguntaEnum),
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',
},
opciones: {
type: 'array',
items: {
type: 'object',
properties: {
valor: { type: 'string', example: 'Si' }
}
}
}
}
}
valor: { type: 'string', example: 'Si' },
},
},
},
},
},
examples: {
cerrada: {
summary: 'Pregunta de opción única',
value: {
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
obligatoria: true,
tipo: TipoPreguntaEnum.Cerrada,
validacion: null,
opciones: [{ valor: 'Sí' }, { valor: 'No' }],
},
},
abierta: {
summary: 'Pregunta de texto libre',
value: {
titulo: '¿Qué mejorarías en nuestro servicio?',
obligatoria: false,
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,
validacion: null,
opciones: [
{ valor: 'Rapidez' },
{ valor: 'Atención al cliente' },
{ valor: 'Facilidad de uso' },
],
},
},
},
}),
ApiResponse({
status: 201,
ApiResponse({
status: 201,
description: 'Pregunta creada correctamente',
schema: {
type: 'object',
@@ -45,19 +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' },
},
},
}),
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,
@@ -68,30 +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' },
},
},
},
}),
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,
@@ -104,36 +160,36 @@ 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',
items: {
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',
@@ -141,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,
@@ -151,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,
@@ -179,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' }),
);
}
}
-18
View File
@@ -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>(PreguntaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+2 -2
View File
@@ -42,8 +42,8 @@ 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;
const savedPregunta = await queryRunner.manager.save(pregunta);
@@ -157,7 +157,7 @@ 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
if (updatePreguntaDto.tipo) {

Some files were not shown because too many files have changed in this diff Show More