1 Commits

264 changed files with 3844 additions and 8063 deletions
-3
View File
@@ -54,6 +54,3 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Uploaded files
/uploads
-3
View File
@@ -1,3 +0,0 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
# Instalar explícitamente las dependencias de autenticación # Instalar explícitamente las dependencias de autenticación
RUN npm i RUN npm install bcryptjs @types/bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt @types/passport-jwt
COPY . . COPY . .
+113 -18
View File
@@ -1,26 +1,121 @@
El Sistema de Registros es una plataforma diseñada para gestionar eventos que requieren registro y control de asistencia mediante formularios personalizados. <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>
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. [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
📌 Tablas principales: <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>
Evento: Representa un evento principal que requiere control de asistencia. Ejemplo: Data & AI Forum 2025. <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)-->
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. ## Description
Participante: Representa a una persona registrada en uno o varios subeventos (Cuestionario). Su asistencia puede ser verificada posteriormente. [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
```sql ## Project setup
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
Note: 'Tabla que representa un evento general, como un foro o conferencia.' ```bash
} $ npm install
``` ```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
# usar validadores en el front end
import { validarRespuesta, FabricaValidadores } from
'./validaciones/validador';
// To validate a single response
const respuesta = 'user@example.com';
const tipoValidacion = 'correo';
const resultado = validarRespuesta(respuesta, tipoValidacion);
if (resultado.valido) {
// proceed
} else {
// show error message: resultado.mensaje
}
// Or directly use the validators
const validadorCorreo = new ValidadorCorreo();
const resultadoCorreo = validadorCorreo.validar('user@example.com');
+11 -11
View File
@@ -11,7 +11,7 @@ services:
MARIADB_DATABASE: formularios_test MARIADB_DATABASE: formularios_test
volumes: volumes:
- mariadb_data:/var/lib/mysql - mariadb_data:/var/lib/mysql
- ./init-scripts:/docker-entrypoint-initdb.d # Base de datos simulada para escolares - ./init-scripts:/docker-entrypoint-initdb.d
restart: unless-stopped restart: unless-stopped
api: api:
@@ -32,16 +32,16 @@ services:
# front: front:
# build: ../formularios_front build: ../formularios_front
# container_name: formularios_front container_name: formularios_front
# restart: always restart: always
# depends_on: depends_on:
# - api - api
# env_file: env_file:
# - ../formularios_front/.env - ../formularios_front/.env
# ports: ports:
# - "4202:3000" - "4202:3000"
volumes: volumes:
+4
View File
@@ -1,5 +1,7 @@
PORT=4200 PORT=4200
db_type=mysql db_type=mysql
db_host=mariadb # <-- uso del nombre del servicio, NO IP db_host=mariadb # <-- uso del nombre del servicio, NO IP
db_port=3306 db_port=3306
@@ -7,4 +9,6 @@ db_username=root # <-- te conectarás con root
db_password=mypass # <-- la misma que MARIADB_ROOT_PASSWORD db_password=mypass # <-- la misma que MARIADB_ROOT_PASSWORD
db_database=formularios_test db_database=formularios_test
url_api_correos=http://host.docker.internal:4100 #http://localhost:4100 url_api_correos=http://host.docker.internal:4100 #http://localhost:4100
+35 -24
View File
@@ -1,24 +1,35 @@
module.exports = { // @ts-check
// parser: '@typescript-eslint/parser', import eslint from '@eslint/js';
// parserOptions: { import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
// project: 'tsconfig.json', import globals from 'globals';
// sourceType: 'module', import tseslint from 'typescript-eslint';
// },
// plugins: ['@typescript-eslint/eslint-plugin'], export default tseslint.config(
// extends: [ {
// 'plugin:@typescript-eslint/recommended', ignores: ['eslint.config.mjs'],
// 'plugin:prettier/recommended', },
// ], eslint.configs.recommended,
// root: true, ...tseslint.configs.recommendedTypeChecked,
// env: { eslintPluginPrettierRecommended,
// node: true, {
// jest: true, languageOptions: {
// }, globals: {
// ignorePatterns: ['.eslintrc.js'], ...globals.node,
// rules: { ...globals.jest,
// '@typescript-eslint/interface-name-prefix': 'off', },
// '@typescript-eslint/explicit-function-return-type': 'off', ecmaVersion: 5,
// '@typescript-eslint/explicit-module-boundary-types': 'off', sourceType: 'module',
// '@typescript-eslint/no-explicit-any': 'off', 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'
},
},
);
-14
View File
@@ -1,14 +0,0 @@
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');
+465 -1400
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -27,8 +27,7 @@
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/serve-static": "^5.0.3", "@nestjs/swagger": "^11.1.0",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
@@ -38,15 +37,15 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"exceljs": "^4.4.0", "express": "^5.1.0",
"multer": "^1.4.5-lts.2",
"mysql2": "^3.14.0", "mysql2": "^3.14.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.21", "typeorm": "^0.3.22"
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -58,7 +57,7 @@
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.13", "@types/multer": "^1.4.12",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -93,5 +92,10 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"compilerOptions": {
"outDir": "./dist"
} }
} }
-10
View File
@@ -1,10 +0,0 @@
{
"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%"
}
+54 -83
View File
@@ -1,101 +1,72 @@
import { import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AdministradorService } from './administrador.service'; import { AdministradorService } from './administrador.service';
import { Administrador } from './entities/administrador.entity'; import { Administrador } from './administrador.entity';
import { CreateAdministradorDto } from './dto/create-administrador.dto'; import { CreateAdministradorDto } from './dto/create-administrador.dto';
import { UpdateAdministradorDto } from './dto/update.administrador.dto'; import { UpdateAdministradorDto } from './dto/update.administrador.dto';
import { LoginAdministradorDto } from './dto/login-administrador.dto'; import { LoginAdministradorDto } from './dto/login-administrador.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { ApiBearerAuth } from '@nestjs/swagger'; import { ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdministradorApiDocumentation } from './docs/administrador.documentation'; import { AdministradorApiDocumentation } from './administrador.documentation';
import { JwtValidationGuard } from 'src/auth/guards/jwt-validation.guard';
import { Roles } from 'src/auth/decorators/roles.decorator';
import { RolesGuard } from 'src/auth/guards/roles.guard';
@AdministradorApiDocumentation.ApiController
@ApiBearerAuth()
@Controller('administrador') @Controller('administrador')
@ApiBearerAuth()
@AdministradorApiDocumentation.ApiController
export class AdministradorController { export class AdministradorController {
constructor(private administradorService: AdministradorService) {}
@Get() constructor(private administradorService: AdministradorService) {}
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiGetAll
getAdministradores(): Promise<Administrador[]> {
return this.administradorService.getAdministradores();
}
@Get(':id') @Get()
@UseGuards(RolesGuard) @UseGuards(JwtAuthGuard)
@UseGuards(JwtValidationGuard) @AdministradorApiDocumentation.ApiGetAll
@Roles('administrador') getAdministradores(): Promise<Administrador[]> {
@AdministradorApiDocumentation.ApiGetOne return this.administradorService.getAdministradores();
getAdministrador(@Param('id', ParseIntPipe) id: number) { }
return this.administradorService.getAdminByIdOrFail(id);
}
@Post() @Get(':id')
@Roles('administrador') @UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiCreate @AdministradorApiDocumentation.ApiGetOne
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) { getAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.createAdministrador(newAdministrador); return this.administradorService.getAdministrador(id);
} }
@Post('login') @Post()
@AdministradorApiDocumentation.ApiLogin @AdministradorApiDocumentation.ApiCreate
login(@Body() loginData: LoginAdministradorDto) { createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
return this.administradorService.login( return this.administradorService.createAdministrador(newAdministrador);
loginData.nombre_usuario, }
loginData.password,
);
}
@Patch(':id/change-password') @Post('login')
@UseGuards(RolesGuard) @AdministradorApiDocumentation.ApiLogin
@UseGuards(JwtValidationGuard) login(@Body() loginData: LoginAdministradorDto) {
@Roles('administrador') return this.administradorService.login(loginData.correo, loginData.password);
@AdministradorApiDocumentation.ApiChangePassword }
changePassword(
@Param('id', ParseIntPipe) id: number,
@Body() changePasswordDto: ChangePasswordDto,
) {
return this.administradorService.changePassword(
id,
changePasswordDto.currentPassword,
changePasswordDto.newPassword,
);
}
@Delete(':id') @Patch(':id/change-password')
@UseGuards(RolesGuard) @UseGuards(JwtAuthGuard)
@UseGuards(JwtValidationGuard) @AdministradorApiDocumentation.ApiChangePassword
@Roles('administrador') changePassword(
@AdministradorApiDocumentation.ApiRemove @Param('id', ParseIntPipe) id: number,
deleteAdministrador(@Param('id', ParseIntPipe) id: number) { @Body() changePasswordDto: ChangePasswordDto
return this.administradorService.deleteAdministrador(id); ) {
} return this.administradorService.changePassword(
id,
changePasswordDto.currentPassword,
changePasswordDto.newPassword
);
}
@Patch(':id') @Delete(':id')
@UseGuards(RolesGuard) @UseGuards(JwtAuthGuard)
@UseGuards(JwtValidationGuard) @AdministradorApiDocumentation.ApiRemove
@Roles('administrador') deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
@AdministradorApiDocumentation.ApiUpdate return this.administradorService.deleteAdministrador(id);
updateAdministrador( }
@Param('id', ParseIntPipe) id: number,
@Body() administrador: UpdateAdministradorDto, @Patch(':id')
) { @UseGuards(JwtAuthGuard)
return this.administradorService.updateAdministrador(id, administrador); @AdministradorApiDocumentation.ApiUpdate
} updateAdministrador(@Param('id', ParseIntPipe) id: number, @Body() administrador: UpdateAdministradorDto) {
return this.administradorService.updateAdministrador(id, administrador);
}
} }
@@ -1,22 +1,15 @@
import { import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common'; import { applyDecorators } from '@nestjs/common';
export class AdministradorApiDocumentation { export class AdministradorApiDocumentation {
// Decorador para toda la clase del controlador // Decorador para toda la clase del controlador
static ApiController = applyDecorators(ApiTags('Administrador')); static ApiController = ApiTags('Administradores');
// Documentación para crear un administrador // Documentación para crear un administrador
static ApiCreate = applyDecorators( static ApiCreate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Registrar un nuevo administrador', summary: 'Registrar un nuevo administrador',
description: description: 'Crea un nuevo administrador con correo, contraseña y tipo de administrador'
'Crea un nuevo administrador con correo, contraseña y tipo de administrador',
}), }),
ApiBody({ ApiBody({
description: 'Datos del administrador a registrar', description: 'Datos del administrador a registrar',
@@ -24,30 +17,25 @@ export class AdministradorApiDocumentation {
type: 'object', type: 'object',
required: ['correo', 'password', 'id_tipo_user'], required: ['correo', 'password', 'id_tipo_user'],
properties: { properties: {
nombre_usuario: {
type: 'string',
example: 'mike',
description: 'Nombre de usuario del administrador',
},
correo: { correo: {
type: 'string', type: 'string',
format: 'email', format: 'email',
example: 'admin@ejemplo.com', example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador', description: 'Correo electrónico del administrador'
}, },
password: { password: {
type: 'string', type: 'string',
format: 'password', format: 'password',
example: 'password', example: 'Password123',
description: 'Contraseña del administrador (mínimo 6 caracteres)', description: 'Contraseña del administrador (mínimo 6 caracteres)'
}, },
id_tipo_user: { id_tipo_user: {
type: 'integer', type: 'integer',
example: 1, example: 1,
description: 'ID del tipo de usuario administrador', description: 'ID del tipo de usuario administrador'
}, }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 201, status: 201,
@@ -57,23 +45,19 @@ export class AdministradorApiDocumentation {
properties: { properties: {
id_admnistrador: { type: 'number', example: 1 }, id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'admin@ejemplo.com' }, correo: { type: 'string', example: 'admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 }, id_tipo_user: { type: 'number', example: 1 }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({ status: 400, description: 'Datos del administrador inválidos' }),
status: 400, ApiResponse({ status: 409, description: 'El correo ya está registrado' })
description: 'Datos del administrador inválidos',
}),
ApiResponse({ status: 409, description: 'El correo ya está registrado' }),
); );
// Documentación para iniciar sesión // Documentación para iniciar sesión
static ApiLogin = applyDecorators( static ApiLogin = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Iniciar sesión como administrador', summary: 'Iniciar sesión como administrador',
description: description: 'Autentica un administrador con correo y contraseña, y devuelve un token JWT'
'Autentica un administrador con correo y contraseña, y devuelve un token JWT',
}), }),
ApiBody({ ApiBody({
description: 'Credenciales de inicio de sesión', description: 'Credenciales de inicio de sesión',
@@ -85,16 +69,16 @@ export class AdministradorApiDocumentation {
type: 'string', type: 'string',
format: 'email', format: 'email',
example: 'admin@ejemplo.com', example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador', description: 'Correo electrónico del administrador'
}, },
password: { password: {
type: 'string', type: 'string',
format: 'password', format: 'password',
example: 'password', example: 'Password123',
description: 'Contraseña del administrador', description: 'Contraseña del administrador'
}, }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -105,35 +89,34 @@ export class AdministradorApiDocumentation {
access_token: { access_token: {
type: 'string', type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Token JWT para autenticación', description: 'Token JWT para autenticación'
}, },
id_administrador: { id_administrador: {
type: 'number', type: 'number',
example: 1, example: 1,
description: 'ID del administrador autenticado', description: 'ID del administrador autenticado'
}, },
correo: { correo: {
type: 'string', type: 'string',
example: 'admin@ejemplo.com', example: 'admin@ejemplo.com',
description: 'Correo del administrador autenticado', description: 'Correo del administrador autenticado'
}, },
tipo_user: { tipo_user: {
type: 'number', type: 'number',
example: 1, example: 1,
description: 'Tipo de usuario del administrador', description: 'Tipo de usuario del administrador'
}, }
}, }
}, }
}), }),
ApiResponse({ status: 401, description: 'Credenciales inválidas' }), ApiResponse({ status: 401, description: 'Credenciales inválidas' })
); );
// Documentación para obtener todos los administradores // Documentación para obtener todos los administradores
static ApiGetAll = applyDecorators( static ApiGetAll = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener todos los administradores', summary: 'Obtener todos los administradores',
description: description: 'Retorna una lista de todos los administradores registrados (requiere autenticación)'
'Retorna una lista de todos los administradores registrados (requiere autenticación)',
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -152,32 +135,28 @@ export class AdministradorApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_tipo_user: { type: 'number', example: 1 }, id_tipo_user: { type: 'number', example: 1 },
tipo_user: { tipo_user: { type: 'string', example: 'Administrador General' }
type: 'string', }
example: 'Administrador General', }
}, }
}, }
}, }
}, }
},
},
},
}), }),
ApiResponse({ status: 401, description: 'No autorizado' }), ApiResponse({ status: 401, description: 'No autorizado' })
); );
// Documentación para obtener un administrador por ID // Documentación para obtener un administrador por ID
static ApiGetOne = applyDecorators( static ApiGetOne = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener un administrador por ID', summary: 'Obtener un administrador por ID',
description: description: 'Retorna los datos de un administrador específico según su ID (requiere autenticación)'
'Retorna los datos de un administrador específico según su ID (requiere autenticación)',
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del administrador', description: 'ID del administrador',
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -194,29 +173,28 @@ export class AdministradorApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_tipo_user: { type: 'number', example: 1 }, id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Administrador General' }, tipo_user: { type: 'string', example: 'Administrador General' }
}, }
}, }
}, }
}, }
}, }
}), }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' }), ApiResponse({ status: 401, description: 'No autorizado' })
); );
// Documentación para actualizar un administrador // Documentación para actualizar un administrador
static ApiUpdate = applyDecorators( static ApiUpdate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Actualizar datos de un administrador', summary: 'Actualizar datos de un administrador',
description: description: 'Actualiza la información de un administrador existente (requiere autenticación)'
'Actualiza la información de un administrador existente (requiere autenticación)',
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del administrador a actualizar', description: 'ID del administrador a actualizar',
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiBody({ ApiBody({
description: 'Datos a actualizar del administrador', description: 'Datos a actualizar del administrador',
@@ -227,22 +205,21 @@ export class AdministradorApiDocumentation {
type: 'string', type: 'string',
format: 'email', format: 'email',
example: 'nuevo_admin@ejemplo.com', example: 'nuevo_admin@ejemplo.com',
description: 'Nuevo correo electrónico del administrador', description: 'Nuevo correo electrónico del administrador'
}, },
password: { password: {
type: 'string', type: 'string',
format: 'password', format: 'password',
example: 'NuevaPassword123', example: 'NuevaPassword123',
description: description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
'Nueva contraseña del administrador (mínimo 6 caracteres)',
}, },
id_tipo_user: { id_tipo_user: {
type: 'integer', type: 'integer',
example: 2, example: 2,
description: 'Nuevo tipo de usuario administrador', description: 'Nuevo tipo de usuario administrador'
}, }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -252,30 +229,26 @@ export class AdministradorApiDocumentation {
properties: { properties: {
id_admnistrador: { type: 'number', example: 1 }, id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' }, correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 2 }, id_tipo_user: { type: 'number', example: 2 }
}, }
}, }
}),
ApiResponse({
status: 400,
description: 'Datos de actualización inválidos',
}), }),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' }), ApiResponse({ status: 401, description: 'No autorizado' })
); );
// Documentación para cambiar contraseña // Documentación para cambiar contraseña
static ApiChangePassword = applyDecorators( static ApiChangePassword = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Cambiar contraseña de un administrador', summary: 'Cambiar contraseña de un administrador',
description: description: 'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)'
'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)',
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del administrador', description: 'ID del administrador',
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiBody({ ApiBody({
description: 'Datos para cambio de contraseña', description: 'Datos para cambio de contraseña',
@@ -287,17 +260,16 @@ export class AdministradorApiDocumentation {
type: 'string', type: 'string',
format: 'password', format: 'password',
example: 'Password123', example: 'Password123',
description: 'Contraseña actual del administrador', description: 'Contraseña actual del administrador'
}, },
newPassword: { newPassword: {
type: 'string', type: 'string',
format: 'password', format: 'password',
example: 'NuevaPassword123', example: 'NuevaPassword123',
description: description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
'Nueva contraseña del administrador (mínimo 6 caracteres)', }
}, }
}, }
},
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -305,30 +277,26 @@ export class AdministradorApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
message: { message: { type: 'string', example: 'Contraseña actualizada correctamente' }
type: 'string', }
example: 'Contraseña actualizada correctamente', }
},
},
},
}), }),
ApiResponse({ status: 400, description: 'Contraseña actual incorrecta' }), ApiResponse({ status: 400, description: 'Contraseña actual incorrecta' }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' }), ApiResponse({ status: 401, description: 'No autorizado' })
); );
// Documentación para eliminar un administrador // Documentación para eliminar un administrador
static ApiRemove = applyDecorators( static ApiRemove = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Eliminar un administrador', summary: 'Eliminar un administrador',
description: description: 'Elimina permanentemente un administrador por su ID (requiere autenticación)'
'Elimina permanentemente un administrador por su ID (requiere autenticación)',
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del administrador a eliminar', description: 'ID del administrador a eliminar',
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -336,11 +304,11 @@ export class AdministradorApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
affected: { type: 'number', example: 1 }, affected: { type: 'number', example: 1 }
}, }
}, }
}), }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' }), ApiResponse({ status: 401, description: 'No autorizado' })
); );
} }
+34
View File
@@ -0,0 +1,34 @@
import { TipoUser } from "src/tipo_user/tipo_user.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Administrador {
@PrimaryGeneratedColumn()
id_admnistrador: number
@Column({ unique: true })
correo: string
@Column()
password: string
/*
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administradores)
@JoinColumn({ name: "id_tipo_user" })
tipoUser: TipoUser;
*/
//Relacion con tipo_user
@Column()
id_tipo_user: number
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador)
tipoUser: TipoUser[]
/*
@OneToMany(() => Evento, (evento) => evento.administrador)
eventos: Evento[];
@OneToMany(() => Asistencia, (asistencia) => asistencia.administrador)
asistencias: Asistencia[];
*/
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AdministradorService } from './administrador.service'; import { AdministradorService } from './administrador.service';
import { AdministradorController } from './administrador.controller'; import { AdministradorController } from './administrador.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Administrador } from './entities/administrador.entity'; import { Administrador } from './administrador.entity';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
+133 -180
View File
@@ -1,6 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Administrador } from './entities/administrador.entity'; import { Administrador } from './administrador.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateAdministradorDto } from './dto/create-administrador.dto'; import { CreateAdministradorDto } from './dto/create-administrador.dto';
import { UpdateAdministradorDto } from './dto/update.administrador.dto'; import { UpdateAdministradorDto } from './dto/update.administrador.dto';
@@ -9,210 +9,163 @@ import { JwtService } from '@nestjs/jwt';
@Injectable() @Injectable()
export class AdministradorService { export class AdministradorService {
constructor(
@InjectRepository(Administrador)
private administradorRepository: Repository<Administrador>,
private jwtService: JwtService,
) {}
async createAdministrador(administrador: CreateAdministradorDto) { constructor(
// Verificar si ya existe un administrador con el mismo correo @InjectRepository(Administrador) private administradorRepository: Repository<Administrador>,
const administradorFound = await this.administradorRepository.findOne({ private jwtService: JwtService
where: { ) {}
correo: administrador.correo,
},
});
if (administradorFound) { async createAdministrador(administrador: CreateAdministradorDto) {
throw new HttpException( // Verificar si ya existe un administrador con el mismo correo
'El correo ya está registrado', const administradorFound = await this.administradorRepository.findOne({
HttpStatus.CONFLICT, where: {
); correo: administrador.correo
}
});
if (administradorFound) {
throw new HttpException('El correo ya está registrado', HttpStatus.CONFLICT);
}
// Encriptar la contraseña
const hashedPassword = await this.hashPassword(administrador.password);
// Crear y guardar el nuevo administrador
const newAdministrador = this.administradorRepository.create({
correo: administrador.correo,
password: hashedPassword,
id_tipo_user: administrador.id_tipo_user
});
const savedAdministrador = await this.administradorRepository.save(newAdministrador);
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
} }
// Encriptar la contraseña async login(correo: string, password: string) {
const hashedPassword = await this.hashPassword(administrador.password); // Buscar administrador por correo
const administrador = await this.administradorRepository.findOne({
where: { correo },
relations: ['tipoUser']
});
// Crear y guardar el nuevo administrador if (!administrador) {
const newAdministrador = this.administradorRepository.create({ throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
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 = // Verificar contraseña
await this.administradorRepository.save(newAdministrador); const isPasswordValid = await bcrypt.compare(password, administrador.password);
if (!isPasswordValid) {
throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
}
// Excluir la contraseña de la respuesta // Generar token JWT
const { password, ...result } = savedAdministrador; const payload = {
return result; sub: administrador.id_admnistrador,
} correo: administrador.correo,
id_tipo_user: administrador.id_tipo_user
};
async login(nombre_usuario: string, password: string) { return {
// Buscar administrador por nombre de usuario access_token: this.jwtService.sign(payload),
const administrador = await this.administradorRepository.findOne({ id_administrador: administrador.id_admnistrador,
where: { nombre_usuario }, correo: administrador.correo,
relations: ['tipoUser'], tipo_user: administrador.id_tipo_user
}); };
if (!administrador) {
throw new HttpException(
'Credenciales inválidas',
HttpStatus.UNAUTHORIZED,
);
} }
// Verificar contraseña async changePassword(id_admnistrador: number, currentPassword: string, newPassword: string) {
const isPasswordValid = await bcrypt.compare( // Buscar administrador
password, const administrador = await this.administradorRepository.findOne({
administrador.password, where: { id_admnistrador }
); });
if (!isPasswordValid) {
throw new HttpException( if (!administrador) {
'Credenciales inválidas', throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
HttpStatus.UNAUTHORIZED, }
);
// 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' };
} }
// Generar token JWT private async hashPassword(password: string): Promise<string> {
const payload = { const salt = await bcrypt.genSalt();
sub: administrador.id_administrador, return bcrypt.hash(password, salt);
correo: administrador.correo,
};
return {
token: this.jwtService.sign(payload),
tipo_usuario: administrador.tipoUser.tipo,
};
}
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,
);
} }
// Verificar contraseña actual getAdministradores() {
const isPasswordValid = await bcrypt.compare( return this.administradorRepository.find({
currentPassword, relations: ['tipoUser'],
administrador.password, select: {
); id_admnistrador: true,
if (!isPasswordValid) { correo: true,
throw new HttpException( id_tipo_user: true
'Contraseña actual incorrecta', }
HttpStatus.BAD_REQUEST, });
);
} }
// Hashear y actualizar nueva contraseña async getAdministrador(id_admnistrador: number) {
administrador.password = await this.hashPassword(newPassword); const administradorFound = await this.administradorRepository.findOne({
await this.administradorRepository.save(administrador); where: {
id_admnistrador
},
relations: ['tipoUser'],
select: {
id_admnistrador: true,
correo: true,
id_tipo_user: true
}
});
return { message: 'Contraseña actualizada correctamente' }; if (!administradorFound) {
} throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
}
private async hashPassword(password: string): Promise<string> { return administradorFound;
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 deleteAdministrador(id_admnistrador: number) {
} const result = await this.administradorRepository.delete({ id_admnistrador });
async getAdminById(id_administrador: number) { if (result.affected === 0) {
const administradorFound = await this.administradorRepository.findOne({ throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
where: { }
id_administrador,
},
relations: ['tipoUser'],
});
return administradorFound; return result;
}
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_admnistrador: number, administrador: UpdateAdministradorDto) {
} const administradorFound = await this.administradorRepository.findOne({
where: {
id_admnistrador
}
});
async updateAdministrador( if (!administradorFound) {
id_administrador: number, throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
administrador: UpdateAdministradorDto, }
) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
});
if (!administradorFound) { // Si se incluye una nueva contraseña, hashearla
throw new HttpException( if (administrador.password) {
'Administrador no encontrado', administrador.password = await this.hashPassword(administrador.password);
HttpStatus.NOT_FOUND, }
);
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;
} }
// 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;
}
} }
@@ -2,31 +2,18 @@ import { IsEmail, IsNotEmpty, IsNumber, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreateAdministradorDto { export class CreateAdministradorDto {
@ApiProperty({ @ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' })
example: 'admin@example.com', @IsEmail({}, { message: 'El correo no es válido' })
description: 'Correo del administrador', @IsNotEmpty({ message: 'El correo es requerido' })
}) correo: string;
@IsEmail({}, { message: 'El correo no es válido' })
@IsNotEmpty({ message: 'El correo es requerido' })
correo: string;
@ApiProperty({ @ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' })
example: 'mike', @MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
description: 'Nombre de usuario del administrador', @IsNotEmpty({ message: 'La contraseña es requerida' })
}) password: string;
@IsNotEmpty({ message: 'El nombre de usuario es requerido' })
nombre_usuario: string;
@ApiProperty({ @ApiProperty({ example: 1, description: 'ID del tipo de usuario' })
example: 'Password123', @IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
description: 'Contraseña del administrador', @IsNotEmpty({ message: 'El tipo de usuario es requerido' })
}) id_tipo_user: number;
@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;
} }
@@ -1,18 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator'; import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class LoginAdministradorDto { export class LoginAdministradorDto {
@ApiProperty({ @ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' })
example: 'mike', @IsEmail({}, { message: 'El correo no es válido' })
description: 'Nombre de usuario del administrador', @IsNotEmpty({ message: 'El correo es requerido' })
}) correo: string;
@IsNotEmpty({ message: 'El nombre de usuario es requerido' })
nombre_usuario: string;
@ApiProperty({ @ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' })
example: 'Password123', @IsNotEmpty({ message: 'La contraseña es requerida' })
description: 'Contraseña del administrador', password: string;
})
@IsNotEmpty({ message: 'La contraseña es requerida' })
password: string;
} }
@@ -1,30 +0,0 @@
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;
}
+6 -75
View File
@@ -1,92 +1,23 @@
import { import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
Controller, import { AlumnosService } from './alumnos.service';
Get, import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
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 { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
@ApiTags('alumnos') @ApiTags('alumnos')
@Controller('alumnos') @Controller('alumnos')
export class AlumnosController { export class AlumnosController {
constructor(private readonly alumnosService: AlumnosService) {} 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') @Get(':cuenta')
@ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' }) @ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' })
@ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' }) @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[200])
@ApiResponse(ALUMNO_RESPONSES[404]) @ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500]) @ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta( async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<AlumnoDto | null> {
@Param('cuenta') cuenta: string, const alumno = await this.alumnosService.findByCuenta(cuenta);
@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) { if (!alumno) {
throw new NotFoundException( throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`);
`No se encontró alumno con número de cuenta ${cuenta}`,
);
} }
return alumno; return alumno;
} }
+43 -2
View File
@@ -3,12 +3,53 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AlumnosController } from './alumnos.controller'; import { AlumnosController } from './alumnos.controller';
import { AlumnosService } from './alumnos.service'; import { AlumnosService } from './alumnos.service';
import { RegistroAlumno } from './entities/registro-alumno.entity'; import { RegistroAlumno } from './entities/registro-alumno.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'), TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
EventoUsuarioModule,
ConfigModule.forRoot({
isGlobal: true, // importante si querés que esté disponible en todos lados
envFilePath: '.env', // opcional si el archivo ya es '.env'
}),
TypeOrmModule.forRootAsync({
name: 'alumnosConnection',
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get<string>('ALUMNOS_DB_HOST'),
port: Number(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],
synchronize: false,
retryAttempts: 10,
retryDelay: 3000,
connectTimeout: 30000,
}),
inject: [ConfigService],
}),
/* TypeOrmModule.forRoot({
name: 'alumnosConnection',
type: 'mysql',
host: process.env.ALUMNOS_DB_HOST ,
port: Number(process.env.ALUMNOS_DB_PORT),
username: process.env.ALUMNOS_DB_USERNAME ,
password: process.env.ALUMNOS_DB_PASSWORD ,
database: process.env.ALUMNOS_DB_DATABASE ,
entities: [RegistroAlumno],
synchronize: false,
retryAttempts: 10, // Intentos para reconectar
retryDelay: 3000, // Tiempo entre reintentos
connectTimeout: 30000, // Timeout de conexión (30 segundos) │ │
}), */
], ],
controllers: [AlumnosController], controllers: [AlumnosController],
providers: [AlumnosService], providers: [AlumnosService],
+3 -103
View File
@@ -1,118 +1,18 @@
import { import { Injectable } from '@nestjs/common';
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity'; 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() @Injectable()
export class AlumnosService { export class AlumnosService {
constructor( constructor(
@InjectRepository(RegistroAlumno, 'alumnosConnection') @InjectRepository(RegistroAlumno, 'alumnosConnection')
private registroAlumnoRepository: Repository<RegistroAlumno>, private registroAlumnoRepository: Repository<RegistroAlumno>,
private readonly eventoUsuarioService: EventoUsuarioService,
) {} ) {}
async procesarArchivo( async findByCuenta(cuenta: string): Promise<RegistroAlumno | null> {
filePath: string, return this.registroAlumnoRepository.findOne({
): 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) }, 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,
};
} }
} }
+70 -33
View File
@@ -2,11 +2,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; 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 { CuestionarioModule } from './cuestionario/cuestionario.module';
import { TipoCuestionarioModule } from './tipo_cuestionario/tipo_cuestionario.module'; import { TipoCuestionarioModule } from './tipo_cuestionario/tipo_cuestionario.module';
import { CuestionarioSeccionModule } from './cuestionario_seccion/cuestionario_seccion.module'; import { CuestionarioSeccionModule } from './cuestionario_seccion/cuestionario_seccion.module';
@@ -19,58 +14,100 @@ import { PreguntaOpcionModule } from './pregunta_opcion/pregunta_opcion.module';
import { RespuestaParticipanteCerradaModule } from './respuesta_participante_cerrada/respuesta_participante_cerrada.module'; import { RespuestaParticipanteCerradaModule } from './respuesta_participante_cerrada/respuesta_participante_cerrada.module';
import { CuestionarioRespondidoModule } from './cuestionario_respondido/cuestionario_respondido.module'; import { CuestionarioRespondidoModule } from './cuestionario_respondido/cuestionario_respondido.module';
import { RespuestaParticipanteAbiertaModule } from './respuesta_participante_abierta/respuesta_participante_abierta.module'; import { RespuestaParticipanteAbiertaModule } from './respuesta_participante_abierta/respuesta_participante_abierta.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventoModule } from './evento/evento.module'; import { EventoModule } from './evento/evento.module';
import { TipoUserModule } from './tipo_user/tipo_user.module'; import { TipoUserModule } from './tipo_user/tipo_user.module';
import { ParticipanteModule } from './participante/participante.module'; import { ParticipanteModule } from './participante/participante.module';
import { QrModule } from './qr/qr.module'; import { QrModule } from './qr/qr.module';
import { AdministradorModule } from './administrador/administrador.module';
import { AsistenciaModule } from './asistencia/asistencia.module'; import { AsistenciaModule } from './asistencia/asistencia.module';
import { ParticipanteEventoModule } from './participante_evento/participante_evento.module'; import { ParticipanteEventoModule } from './participante_evento/participante_evento.module';
import { ValidacionesModule } from './validaciones/validaciones.module'; import { ValidacionesModule } from './validaciones/validaciones.module';
import { AlumnosModule } from './alumnos/alumnos.module'; import { AlumnosModule } from './alumnos/alumnos.module';
import { TrabajadoresModule } from './trabajadores/trabajadores.module'; import { AuthModule } from './auth/auth.module';
import { TipoEventoModule } from './tipo_evento/tipo_evento.module'; import { RegistroAlumno } from './alumnos/entities/registro-alumno.entity';
import { EventoUsuarioModule } from './evento_usuario/evento_usuario.module'; import { ImagenController } from './imagen/imagen.controller';
import { ImagenModule } from './imagen/imagen.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({
TypeOrmModule.forRootAsync({ isGlobal: true,
imports: [ConfigModule],
useFactory: createMainDbConfig,
inject: [ConfigService],
}), }),
TypeOrmModule.forRootAsync({
TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
name: 'alumnosConnection',
useFactory: createAlumnosDbConfig,
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: config.get<'mysql'>('DB_TYPE'),
host: config.get<string>('DB_HOST'),
port: +(config.get<number>('DB_PORT') || 3306),
username: config.get<string>('DB_USERNAME'),
password: config.get<string>('DB_PASSWORD'),
database: config.get<string>('DB_DATABASE'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
//dropSchema: true, // elimina la base de datos
}),
/*
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
}), }),
AdministradorModule, TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
AlumnosModule,
AsistenciaModule,
AuthModule, AuthModule,
CuestionarioModule, CuestionarioModule,
CuestionarioRespondidoModule, TipoCuestionarioModule,
CuestionarioSeccionModule, CuestionarioSeccionModule,
EventoModule,
OpcionModule,
ParticipanteEventoModule,
ParticipanteModule,
PreguntaModule,
PreguntaOpcionModule,
RespuestaParticipanteAbiertaModule,
RespuestaParticipanteCerradaModule,
SeccionModule, SeccionModule,
SeccionPreguntaModule, SeccionPreguntaModule,
TipoCuestionarioModule, PreguntaModule,
TipoEventoModule,
TipoPreguntaModule, TipoPreguntaModule,
OpcionModule, PreguntaOpcionModule,
RespuestaParticipanteCerradaModule,
CuestionarioRespondidoModule,
RespuestaParticipanteAbiertaModule,
EventoModule,
TipoUserModule, TipoUserModule,
ValidacionesModule, ParticipanteModule,
QrModule, QrModule,
TrabajadoresModule, AdministradorModule,
EventoUsuarioModule, AsistenciaModule,
ParticipanteEventoModule,
ValidacionesModule,
AlumnosModule,
ImagenModule,
], ],
controllers: [AppController], controllers: [AppController],
+1 -1
View File
@@ -1,6 +1,6 @@
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 { AsistenciaService } from './asistencia.service'; import { AsistenciaService } from './asistencia.service';
import { Asistencia } from './entities/asistencia.entity'; import { Asistencia } from './asistencia.entity';
import { CreateAsistenciaDto } from './dto/create-asistencia.dto'; import { CreateAsistenciaDto } from './dto/create-asistencia.dto';
import { UpdateAsistenciaDto } from './dto/update.asistencia.dto'; import { UpdateAsistenciaDto } from './dto/update.asistencia.dto';
+41
View File
@@ -0,0 +1,41 @@
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 { AsistenciaService } from './asistencia.service';
import { AsistenciaController } from './asistencia.controller'; import { AsistenciaController } from './asistencia.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Asistencia } from './entities/asistencia.entity'; import { Asistencia } from './asistencia.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Asistencia])], imports: [TypeOrmModule.forFeature([Asistencia])],
+1 -1
View File
@@ -1,6 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Asistencia } from './entities/asistencia.entity'; import { Asistencia } from './asistencia.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateAsistenciaDto } from './dto/create-asistencia.dto'; import { CreateAsistenciaDto } from './dto/create-asistencia.dto';
import { UpdateAsistenciaDto } from './dto/update.asistencia.dto'; import { UpdateAsistenciaDto } from './dto/update.asistencia.dto';
@@ -1,25 +0,0 @@
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;
}
+6 -12
View File
@@ -2,23 +2,17 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { Administrador } from '../administrador/administrador.entity';
import { Administrador } from '../administrador/entities/administrador.entity';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
@Module({ @Module({
imports: [ imports: [
ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ JwtModule.register({
imports: [ConfigModule], secret: process.env.JWT_SECRET || 'tu_clave_secreta',
inject: [ConfigService], signOptions: {
useFactory: (configService: ConfigService) => ({ expiresIn: '24h', // Tokens expiran en 24 horas
secret: configService.get<string>('JWT_SECRET', 'tu_clave_secreta'), },
signOptions: {
expiresIn: '24h',
},
}),
}), }),
TypeOrmModule.forFeature([Administrador]), TypeOrmModule.forFeature([Administrador]),
], ],
-5
View File
@@ -1,5 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ('administrador' | 'staff')[]) =>
SetMetadata(ROLES_KEY, roles);
-52
View File
@@ -1,52 +0,0 @@
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
@@ -1,46 +0,0 @@
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;
}
}
+6 -5
View File
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Administrador } from '../administrador/entities/administrador.entity'; import { Administrador } from '../administrador/administrador.entity';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -19,14 +19,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
async validate(payload: any) { async validate(payload: any) {
const { sub: id_administrador } = payload; const { sub: id_admnistrador } = payload;
const administrador = await this.administradorRepository.findOne({ const administrador = await this.administradorRepository.findOne({
where: { id_administrador }, where: { id_admnistrador },
select: { select: {
id_administrador: true, id_admnistrador: true,
correo: true, correo: true,
}, id_tipo_user: true
}
}); });
if (!administrador) { if (!administrador) {
@@ -0,0 +1,20 @@
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();
});
});
+4 -168
View File
@@ -1,31 +1,9 @@
import { import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
ParseIntPipe,
UploadedFile,
UnprocessableEntityException,
UploadedFiles,
BadRequestException,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { CuestionarioService } from './cuestionario.service'; import { CuestionarioService } from './cuestionario.service';
import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { CuestionarioApiDocumentation } from './cuestionario.documentation';
import { diskStorage } from 'multer'; import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
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') @Controller('cuestionario')
@CuestionarioApiDocumentation.ApiController @CuestionarioApiDocumentation.ApiController
@@ -38,151 +16,12 @@ export class CuestionarioController {
return this.cuestionarioService.create(createCuestionarioDto); 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() @Get()
@CuestionarioApiDocumentation.ApiGetAll @CuestionarioApiDocumentation.ApiGetAll
findAll() { findAll() {
return this.cuestionarioService.findAll(); return this.cuestionarioService.findAll();
} }
@Get('recientes')
@CuestionarioApiDocumentation.ApiGetAll
findAllRecientes() {
return this.cuestionarioService.findAllRecientes();
}
@Get(':id') @Get(':id')
@CuestionarioApiDocumentation.ApiGetOne @CuestionarioApiDocumentation.ApiGetOne
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -197,10 +36,7 @@ async cargaMasiva(@UploadedFile() file: Express.Multer.File) {
@Patch(':id') @Patch(':id')
@CuestionarioApiDocumentation.ApiUpdate @CuestionarioApiDocumentation.ApiUpdate
update( update(@Param('id') id: string, @Body() updateCuestionarioDto: UpdateCuestionarioDto) {
@Param('id') id: string,
@Body() updateCuestionarioDto: UpdateCuestionarioDto,
) {
return this.cuestionarioService.update(+id, updateCuestionarioDto); return this.cuestionarioService.update(+id, updateCuestionarioDto);
} }
@@ -1,82 +1,87 @@
import { import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
ApiBody, import { feriaSexualidad } from '../../utils/crear_formulario_feria';
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
/* import { feriaSexualidad } from '../../../utils/crear_formulario_feria'; */
import { applyDecorators } from '@nestjs/common'; import { applyDecorators } from '@nestjs/common';
import { FormularioDto } from '../dto/formulario.schema'; import { FormularioDto, FormularioFeriaSchema } from './dto/formulario.schema';
import {
CreateCuestionarioDto,
CreateCuestionarioEventoDto,
} from '../dto/create-cuestionario.dto';
import { ejemploCreateEventoWithCuestionario, ejemploCuestionarioAlumno } from './cuestionario.examples';
export class CuestionarioApiDocumentation { export class CuestionarioApiDocumentation {
// Decoradores para toda la clase del controlador // Decoradores para toda la clase del controlador
static ApiController = ApiTags('Cuestionario'); static ApiController = ApiTags('Cuestionarios');
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 // Documentación para obtener el formulario completo
static ApiGetFormulario = applyDecorators( static ApiGetFormulario = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener formulario completo', summary: 'Obtener formulario completo',
description: 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'
'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({ ApiParam({
name: 'id', name: 'id',
description: 'ID del cuestionario', description: 'ID del cuestionario',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), ApiResponse({
status: 200,
description: 'Formulario completo con todas sus secciones, preguntas y opciones. Las preguntas incluyen el atributo "validacion" si tienen reglas de validación específicas (como "correo", "cuenta_alumno", "telefono", "nombre", etc.)',
type: FormularioDto,
content: {
'application/json': {
schema: { $ref: getSchemaPath(FormularioDto) },
example: feriaSexualidad
}
}
}),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' })
); );
static ApiCreateWithEvento = applyDecorators( // Documentación para crear un cuestionario
static ApiCreate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Crear un nuevo cuestionario con nuevo evento', summary: 'Crear un nuevo cuestionario',
description: description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones, incluyendo validaciones personalizadas'
'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).', }),
ApiBody({
description: 'Datos del cuestionario a crear. Para las preguntas de tipo "Abierta", se puede especificar un tipo de validación usando la propiedad "validacion" con valores como: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
type: FormularioDto,
examples: {
feriaSexualidad: {
summary: 'Ejemplo de cuestionario para feria de sexualidad',
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad, ahora con validaciones específicas para cada tipo de campo (correo, nombre, cuenta_alumno, etc.)',
value: feriaSexualidad
}
}
}), }),
ApiResponse({ ApiResponse({
status: 201, status: 201,
description: 'Cuestionario y evento creados exitosamente', description: 'Cuestionario creado correctamente. Los campos con la propiedad "validacion" se validarán según su tipo: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
}), schema: {
ApiResponse({ properties: {
status: 400, success: { type: 'boolean', example: true },
description: 'Datos inválidos', cuestionario: {
}), type: 'object',
ApiBody({ properties: {
type: CreateCuestionarioEventoDto, id_cuestionario: { type: 'number', example: 1 },
examples: { nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
ejemplo_con_evento_nuevo: ejemploCreateEventoWithCuestionario, 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 // Documentación para obtener todos los cuestionarios
static ApiGetAll = applyDecorators( static ApiGetAll = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener todos los cuestionarios', summary: 'Obtener todos los cuestionarios',
description: 'Retorna una lista de todos los cuestionarios registrados', description: 'Retorna una lista de todos los cuestionarios registrados'
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -87,35 +92,32 @@ export class CuestionarioApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_cuestionario: { type: 'number', example: 1 }, id_cuestionario: { type: 'number', example: 1 },
nombre_form: { nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
type: 'string',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán',
},
descripcion: { type: 'string' }, descripcion: { type: 'string' },
contador_secciones: { type: 'number', example: 1 }, contador_secciones: { type: 'number', example: 1 },
editable: { type: 'boolean', example: true }, editable: { type: 'boolean', example: true },
fecha_fin: { type: 'string', format: 'date-time' }, fecha_fin: { type: 'string', format: 'date-time' },
fecha_inicio: { 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 // Documentación para obtener un cuestionario por ID
static ApiGetOne = applyDecorators( static ApiGetOne = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener un cuestionario por ID', 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({ ApiParam({
name: 'id', name: 'id',
description: 'ID del cuestionario', description: 'ID del cuestionario',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -124,51 +126,45 @@ export class CuestionarioApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_cuestionario: { type: 'number', example: 1 }, id_cuestionario: { type: 'number', example: 1 },
nombre_form: { nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
type: 'string',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán',
},
descripcion: { type: 'string' }, descripcion: { type: 'string' },
contador_secciones: { type: 'number', example: 1 }, contador_secciones: { type: 'number', example: 1 },
editable: { type: 'boolean', example: true }, editable: { type: 'boolean', example: true },
fecha_fin: { type: 'string', format: 'date-time' }, fecha_fin: { type: 'string', format: 'date-time' },
fecha_inicio: { 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: 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 // Documentación para actualizar un cuestionario
static ApiUpdate = applyDecorators( static ApiUpdate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Actualizar un cuestionario', summary: 'Actualizar un cuestionario',
description: 'Actualiza los datos de un cuestionario existente', description: 'Actualiza los datos de un cuestionario existente'
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del cuestionario a actualizar', description: 'ID del cuestionario a actualizar',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiBody({ ApiBody({
description: 'Datos a actualizar del cuestionario', description: 'Datos a actualizar del cuestionario',
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
nombre_form: { nombre_form: { type: 'string', example: 'Nombre actualizado del formulario' },
type: 'string',
example: 'Nombre actualizado del formulario',
},
descripcion: { type: 'string', example: 'Descripción actualizada' }, descripcion: { type: 'string', example: 'Descripción actualizada' },
fecha_inicio: { type: 'string', format: 'date-time' }, fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' }, fecha_fin: { type: 'string', format: 'date-time' },
editable: { type: 'boolean', example: true }, editable: { type: 'boolean', example: true }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -176,30 +172,27 @@ export class CuestionarioApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { 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: 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 // Documentación para eliminar un cuestionario
static ApiRemove = applyDecorators( static ApiRemove = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Eliminar un cuestionario', summary: 'Eliminar un cuestionario',
description: 'Elimina permanentemente un cuestionario por su ID', description: 'Elimina permanentemente un cuestionario por su ID'
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID del cuestionario a eliminar', description: 'ID del cuestionario a eliminar',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -207,11 +200,11 @@ export class CuestionarioApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
affected: { type: 'number', example: 1 }, affected: { type: 'number', example: 1 }
}, }
}, }
}), }),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' }), ApiResponse({ status: 500, description: 'Error interno del servidor' })
); );
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity'; import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity'; import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Evento } from '../evento/entities/evento.entity'; import { Evento } from '../evento/evento.entity';
import { EventoService } from '../evento/evento.service'; import { EventoService } from '../evento/evento.service';
@Module({ @Module({
@@ -0,0 +1,18 @@
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();
});
});
+110 -517
View File
@@ -1,32 +1,19 @@
import { import { Injectable, NotFoundException } from '@nestjs/common';
Injectable,
InternalServerErrorException,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In, MoreThan } from 'typeorm'; import { Repository, DataSource, In } from 'typeorm';
import { import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
CreateCuestionarioDto,
CreateCuestionarioEventoDto,
} from './dto/create-cuestionario.dto';
import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { Cuestionario } from './entities/cuestionario.entity'; import { Cuestionario } from './entities/cuestionario.entity';
import { Seccion } from '../seccion/entities/seccion.entity'; import { Seccion } from '../seccion/entities/seccion.entity';
import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity'; import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Pregunta, TiposValidacion } from '../pregunta/entities/pregunta.entity'; import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity'; import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity';
import { Opcion } from '../opcion/entities/opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
TipoPregunta, import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
TipoPreguntaEnum, import { Evento } from '../evento/evento.entity';
} from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { Evento } from '../evento/entities/evento.entity';
import { EventoService } from '../evento/evento.service'; import { EventoService } from '../evento/evento.service';
import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto';
import * as ExcelJS from 'exceljs';
import { PLANTILLAS } from './plantillas';
@Injectable() @Injectable()
export class CuestionarioService { export class CuestionarioService {
@@ -50,397 +37,64 @@ export class CuestionarioService {
@InjectRepository(Evento) @InjectRepository(Evento)
private eventoRepository: Repository<Evento>, private eventoRepository: Repository<Evento>,
private dataSource: DataSource, private dataSource: DataSource,
private eventoService: EventoService, 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) { async create(createCuestionarioDto: CreateCuestionarioDto) {
// Verificar que el evento exista
await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento);
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
// Verificar si se especificó un evento y búscarlo/crearlo
let idEvento: number | undefined = undefined;
if (createCuestionarioDto.evento) {
// Buscar si el evento ya existe
let evento = await this.eventoRepository.findOne({
where: { nombre_evento: createCuestionarioDto.evento }
});
// Si no existe, crearlo
if (!evento) {
const nuevoEvento = {
nombre_evento: createCuestionarioDto.evento,
tipo_evento: 'Predeterminado',
fecha_inicio: createCuestionarioDto.fecha_inicio || new Date(),
fecha_fin: createCuestionarioDto.fecha_fin || new Date(Date.now() + 86400000) // 1 día después si no hay fecha
};
evento = await queryRunner.manager.save(Evento, nuevoEvento);
console.log(`Evento creado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
} else {
console.log(`Evento encontrado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
}
idEvento = evento.id_evento;
}
// 1. Crear cuestionario // 1. Crear cuestionario
const cuestionario = new Cuestionario(); const cuestionario = new Cuestionario();
cuestionario.nombre_form = createCuestionarioDto.nombre_form; cuestionario.nombre_form = createCuestionarioDto.nombre_form;
cuestionario.descripcion = createCuestionarioDto.descripcion; cuestionario.descripcion = createCuestionarioDto.descripcion;
cuestionario.fecha_inicio = new Date(createCuestionarioDto.fecha_inicio); cuestionario.fecha_inicio = createCuestionarioDto.fecha_inicio ? new Date(createCuestionarioDto.fecha_inicio) : undefined;
cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin); cuestionario.fecha_fin = createCuestionarioDto.fecha_fin ? new Date(createCuestionarioDto.fecha_fin) : undefined;
cuestionario.id_tipo_cuestionario = cuestionario.id_tipo_cuestionario = createCuestionarioDto.id_tipo_cuestionario;
createCuestionarioDto.id_tipo_cuestionario; cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0;
cuestionario.id_tipo_evento = createCuestionarioDto.id_tipo_evento;
cuestionario.contador_secciones =
createCuestionarioDto.secciones?.length || 0;
cuestionario.editable = true; cuestionario.editable = true;
cuestionario.id_evento = createCuestionarioDto.id_evento;
cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo; // Asociar el cuestionario con el evento si existe
cuestionario.mensaje_correo = createCuestionarioDto.mensaje_correo; if (idEvento !== undefined) {
cuestionario.id_evento = idEvento;
}
const savedCuestionario = await queryRunner.manager.save(cuestionario); const savedCuestionario = await queryRunner.manager.save(cuestionario);
// 2. Crear secciones y vincularlas // 2. Crear secciones y vincularlas al cuestionario
if ( if (createCuestionarioDto.secciones && createCuestionarioDto.secciones.length > 0) {
createCuestionarioDto.secciones &&
createCuestionarioDto.secciones?.length > 0
) {
for (let i = 0; i < createCuestionarioDto.secciones.length; i++) { for (let i = 0; i < createCuestionarioDto.secciones.length; i++) {
const seccionDto = createCuestionarioDto.secciones[i]; const seccionDto = createCuestionarioDto.secciones[i];
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);
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. 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];
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 pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo;
pregunta.obligatoria = preguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = idTipoPregunta;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
pregunta.validacion = preguntaDto.validacion || undefined;
const savedPregunta = await queryRunner.manager.save(pregunta);
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 si las hay
if (preguntaDto.opciones && preguntaDto.opciones?.length > 0) {
for (let k = 0; k < preguntaDto.opciones.length; k++) {
const opcionDto = preguntaDto.opciones[k];
const opcion = new Opcion();
opcion.opcion = opcionDto.valor;
const savedOpcion = await queryRunner.manager.save(opcion);
const preguntaOpcion = new PreguntaOpcion();
preguntaOpcion.id_pregunta = savedPregunta.id_pregunta;
preguntaOpcion.opcion = savedOpcion;
preguntaOpcion.posicion = k + 1;
await queryRunner.manager.save(preguntaOpcion);
}
}
}
}
}
}
await queryRunner.commitTransaction();
return {
success: true,
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 // Crear sección
const seccion = new Seccion(); const seccion = new Seccion();
seccion.titulo = seccionDto.titulo; seccion.titulo = seccionDto.titulo;
@@ -451,8 +105,7 @@ export class CuestionarioService {
// Vincular sección con cuestionario // Vincular sección con cuestionario
const cuestionarioSeccion = new CuestionarioSeccion(); const cuestionarioSeccion = new CuestionarioSeccion();
cuestionarioSeccion.id_cuestionario = cuestionarioSeccion.id_cuestionario = savedCuestionario.id_cuestionario;
savedCuestionario.id_cuestionario;
cuestionarioSeccion.id_seccion = savedSeccion.id_seccion; cuestionarioSeccion.id_seccion = savedSeccion.id_seccion;
cuestionarioSeccion.posicion = i + 1; cuestionarioSeccion.posicion = i + 1;
@@ -463,39 +116,28 @@ export class CuestionarioService {
for (let j = 0; j < seccionDto.preguntas.length; j++) { for (let j = 0; j < seccionDto.preguntas.length; j++) {
const preguntaDto = seccionDto.preguntas[j]; const preguntaDto = seccionDto.preguntas[j];
let idTipoPregunta = 7; // Mapear directamente el tipo de pregunta a su ID correspondiente
let idTipoPregunta = 7; // Valor por defecto (desconocido)
switch (preguntaDto.tipo) { // Mapeamos el tipo de pregunta a su ID correspondiente
case TipoPreguntaEnum.AbiertaParrafo: if (preguntaDto.tipo === 'Cerrada') {
idTipoPregunta = 1; idTipoPregunta = 1;
break; } else if (preguntaDto.tipo === 'Abierta') {
case TipoPreguntaEnum.AbiertaRespuestaCorta: idTipoPregunta = 2;
idTipoPregunta = 2; } else if (preguntaDto.tipo === 'Multiple') {
break; idTipoPregunta = 3;
case TipoPreguntaEnum.Cerrada: } else {
idTipoPregunta = 3; console.warn(`Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`);
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 = { const tipoPregunta = { id_tipo: idTipoPregunta, tipo_pregunta: preguntaDto.tipo };
id_tipo: idTipoPregunta,
tipo_pregunta: preguntaDto.tipo,
};
// Crear pregunta // Crear pregunta
const pregunta = new Pregunta(); const pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo; pregunta.pregunta = preguntaDto.titulo;
pregunta.obligatoria = preguntaDto.obligatoria || false; pregunta.obligatoria = preguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo; pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0; pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
pregunta.validacion = preguntaDto.validacion || undefined; pregunta.validacion = preguntaDto.validacion || undefined;
@@ -536,11 +178,10 @@ export class CuestionarioService {
} }
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return { return {
success: true, success: true,
message: 'Cuestionario y evento creados exitosamente', cuestionario: savedCuestionario,
id_cuestionario: savedCuestionario.id_cuestionario,
id_evento: eventoExistente.id_evento,
}; };
} catch (error) { } catch (error) {
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
@@ -550,42 +191,13 @@ 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() { findAll() {
return this.cuestionarioRepository.find(); return this.cuestionarioRepository.find();
} }
async findOne(id: number) { async findOne(id: number) {
const cuestionario = await this.cuestionarioRepository.findOne({ const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id }, where: { id_cuestionario: id }
}); });
if (!cuestionario) { if (!cuestionario) {
@@ -599,7 +211,7 @@ export class CuestionarioService {
// Obtener el cuestionario básico // Obtener el cuestionario básico
const cuestionario = await this.cuestionarioRepository.findOne({ const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id }, where: { id_cuestionario: id },
relations: ['evento'], // Incluir la relación con el evento relations: ['evento'] // Incluir la relación con el evento
}); });
if (!cuestionario) { if (!cuestionario) {
@@ -610,82 +222,73 @@ export class CuestionarioService {
// para el tipo de cuestionario según get_formulario_feria.ts // para el tipo de cuestionario según get_formulario_feria.ts
const tipoCuestionario = { const tipoCuestionario = {
id_tipo_cuestionario: 1, id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta', tipo_cuestionario: 'Encuesta'
}; };
// Mapeador de ID de tipo de pregunta a nombres conocidos // Mapeador de ID de tipo de pregunta a nombres conocidos
const tiposPreguntaMap = { const tiposPreguntaMap = {
1: { id_tipo: 1, tipo_pregunta: TipoPreguntaEnum.AbiertaParrafo }, 1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
2: { id_tipo: 2, tipo_pregunta: TipoPreguntaEnum.AbiertaRespuestaCorta }, 2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
3: { id_tipo: 3, tipo_pregunta: TipoPreguntaEnum.Cerrada }, 3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
4: { id_tipo: 4, tipo_pregunta: TipoPreguntaEnum.Multiple },
}; };
// Obtener las relaciones cuestionario-seccion // Obtener las relaciones cuestionario-seccion
const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find( const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find({
{ where: { id_cuestionario: id },
where: { id_cuestionario: id }, order: { posicion: 'ASC' }
order: { posicion: 'ASC' }, });
},
);
// Obtener todas las secciones // 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({ const secciones = await this.seccionRepository.find({
where: { id_seccion: In(seccionIds) }, where: { id_seccion: In(seccionIds) }
}); });
const seccionesFormateadas = await Promise.all( const seccionesFormateadas = await Promise.all(
cuestionarioSecciones.map(async (cs) => { 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; if (!seccion) return null;
// Obtener relaciones sección-pregunta // Obtener relaciones sección-pregunta
const seccionPreguntas = await this.seccionPreguntaRepository.find({ const seccionPreguntas = await this.seccionPreguntaRepository.find({
where: { id_seccion: seccion.id_seccion }, where: { id_seccion: seccion.id_seccion },
order: { posicion: 'ASC' }, order: { posicion: 'ASC' }
}); });
// Obtener preguntas // Obtener preguntas
const preguntaIds = seccionPreguntas.map((sp) => sp.id_pregunta); const preguntaIds = seccionPreguntas.map(sp => sp.id_pregunta);
const preguntas = await this.preguntaRepository.find({ const preguntas = await this.preguntaRepository.find({
where: { id_pregunta: In(preguntaIds) }, where: { id_pregunta: In(preguntaIds) }
}); });
// Formatear cada pregunta según el formato requerido // Formatear cada pregunta según el formato requerido
const preguntasFormateadas = await Promise.all( const preguntasFormateadas = await Promise.all(
seccionPreguntas.map(async (sp) => { seccionPreguntas.map(async (sp) => {
const pregunta = preguntas.find( const pregunta = preguntas.find(p => p.id_pregunta === sp.id_pregunta);
(p) => p.id_pregunta === sp.id_pregunta,
);
if (!pregunta) return null; if (!pregunta) return null;
// Usar el mapeo de tipos de pregunta definido arriba // Usar el mapeo de tipos de pregunta definido arriba
const tipoPregunta = tiposPreguntaMap[ const tipoPregunta = tiposPreguntaMap[pregunta.id_tipo_pregunta] ||
pregunta.id_tipo_pregunta { id_tipo: pregunta.id_tipo_pregunta, tipo_pregunta: 'Desconocido' };
] || {
id_tipo: pregunta.id_tipo_pregunta,
tipo_pregunta: 'Desconocido',
};
// Obtener opciones para preguntas de tipo multiple/radio // Obtener opciones para preguntas de tipo multiple/radio
const preguntaOpciones = await this.preguntaOpcionRepository.find({ const preguntaOpciones = await this.preguntaOpcionRepository.find({
where: { id_pregunta: pregunta.id_pregunta }, where: { id_pregunta: pregunta.id_pregunta },
relations: ['opcion'], relations: ['opcion'],
order: { posicion: 'ASC' }, order: { posicion: 'ASC' }
}); });
// Formatear las opciones según el formato requerido // 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, id_pregunta_opcion: po.id_pregunta_opcion,
posicion: po.posicion, posicion: po.posicion,
id_opcion: po.opcion.id_opcion, id_opcion: po.opcion.id_opcion,
opcion: { opcion: {
id_opcion: po.opcion.id_opcion, id_opcion: po.opcion.id_opcion,
opcion: po.opcion.opcion, opcion: po.opcion.opcion
}, }
})); }));
// Construir objeto de pregunta según el formato requerido // Construir objeto de pregunta según el formato requerido
@@ -698,16 +301,17 @@ export class CuestionarioService {
contador_opcion: pregunta.contador_opcion, contador_opcion: pregunta.contador_opcion,
obligatoria: pregunta.obligatoria, obligatoria: pregunta.obligatoria,
id_tipo_pregunta: pregunta.id_tipo_pregunta, id_tipo_pregunta: pregunta.id_tipo_pregunta,
id_opcion_dependiente: pregunta.id_opcion_dependiente,
validacion: pregunta.validacion, validacion: pregunta.validacion,
tipo_pregunta: { tipo_pregunta: {
id_tipo: tipoPregunta.id_tipo, 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 // Construir objeto de sección según el formato requerido
return { return {
@@ -717,50 +321,39 @@ export class CuestionarioService {
id_seccion: seccion.id_seccion, id_seccion: seccion.id_seccion,
contador_pregunta: seccion.contador_pregunta, contador_pregunta: seccion.contador_pregunta,
descripcion: seccion.descripcion, 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 // Construir objeto final respetando el formato de get_formulario_feria.ts
return { return {
tipo_cuestionario: { tipo_cuestionario: {
id_tipo_cuestionario: tipoCuestionario.id_tipo_cuestionario, id_tipo_cuestionario: tipoCuestionario.id_tipo_cuestionario,
tipo_cuestionario: tipoCuestionario.tipo_cuestionario, tipo_cuestionario: tipoCuestionario.tipo_cuestionario
}, },
evento: cuestionario.evento evento: cuestionario.evento ? {
? { id_evento: cuestionario.evento.id_evento,
id_evento: cuestionario.evento.id_evento, nombre_evento: cuestionario.evento.nombre_evento,
nombre_evento: cuestionario.evento.nombre_evento, tipo_evento: cuestionario.evento.tipo_evento,
descripcion_evento: cuestionario.evento.descripcion_evento, fecha_inicio: cuestionario.evento.fecha_inicio ? cuestionario.evento.fecha_inicio.toISOString() : null,
banner: cuestionario.evento.banner, fecha_fin: cuestionario.evento.fecha_fin ? cuestionario.evento.fecha_fin.toISOString() : null
tipo_evento: cuestionario.evento.tipo_evento, } : null,
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: { cuestionario: {
id_cuestionario: cuestionario.id_cuestionario, id_cuestionario: cuestionario.id_cuestionario,
nombre_form: cuestionario.nombre_form, nombre_form: cuestionario.nombre_form,
contador_secciones: cuestionario.contador_secciones, contador_secciones: cuestionario.contador_secciones,
descripcion: cuestionario.descripcion, descripcion: cuestionario.descripcion,
editable: cuestionario.editable, editable: cuestionario.editable,
fecha_inicio: cuestionario.fecha_inicio fecha_inicio: cuestionario.fecha_inicio ? cuestionario.fecha_inicio.toISOString() : null,
? cuestionario.fecha_inicio.toISOString() fecha_fin: cuestionario.fecha_fin ? cuestionario.fecha_fin.toISOString() : null,
: null, id_cuestionario_original: cuestionario.id_cuestionario_original,
fecha_fin: cuestionario.fecha_fin
? cuestionario.fecha_fin.toISOString()
: null,
id_tipo_cuestionario: cuestionario.id_tipo_cuestionario, id_tipo_cuestionario: cuestionario.id_tipo_cuestionario,
id_evento: cuestionario.id_evento || null, id_evento: cuestionario.id_evento || null,
secciones: seccionesFormateadas, secciones: seccionesFormateadas
}, }
}; };
} }
@@ -769,7 +362,7 @@ export class CuestionarioService {
if (updateCuestionarioDto.evento) { if (updateCuestionarioDto.evento) {
// Buscar o crear evento // Buscar o crear evento
let evento = await this.eventoRepository.findOne({ let evento = await this.eventoRepository.findOne({
where: { nombre_evento: updateCuestionarioDto.evento }, where: { nombre_evento: updateCuestionarioDto.evento }
}); });
if (!evento) { if (!evento) {
@@ -778,7 +371,7 @@ export class CuestionarioService {
nombre_evento: updateCuestionarioDto.evento, nombre_evento: updateCuestionarioDto.evento,
tipo_evento: 'Predeterminado', tipo_evento: 'Predeterminado',
fecha_inicio: new Date(), fecha_inicio: new Date(),
fecha_fin: new Date(Date.now() + 86400000), fecha_fin: new Date(Date.now() + 86400000)
}; };
evento = await this.eventoRepository.save(nuevoEvento); evento = await this.eventoRepository.save(nuevoEvento);
@@ -1,224 +0,0 @@
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' },
],
},
],
},
],
},
};
+12 -59
View File
@@ -1,30 +1,12 @@
import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto'; import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto';
import { import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { OmitType } from '@nestjs/mapped-types';
export class CreateCuestionarioDto { export class CreateCuestionarioDto {
@ApiProperty({
description: 'ID del evento al que pertenece el cuestionario',
example: 1,
required: false,
})
@IsNotEmpty()
@IsNumber()
id_evento: number;
@ApiProperty({ @ApiProperty({
description: 'Nombre del formulario', description: 'Nombre del formulario',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán', example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@@ -33,7 +15,7 @@ export class CreateCuestionarioDto {
@ApiProperty({ @ApiProperty({
description: 'Descripción del formulario', description: 'Descripción del formulario',
example: 'Formulario de registro para la feria...', example: 'Formulario de registro para la feria...',
required: false, required: false
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -42,66 +24,42 @@ export class CreateCuestionarioDto {
@ApiProperty({ @ApiProperty({
description: 'Fecha de inicio', description: 'Fecha de inicio',
example: '2025-03-26T00:00:00', example: '2025-03-26T00:00:00',
required: false, required: false
}) })
@IsOptional()
@IsDateString() @IsDateString()
fecha_inicio: Date; fecha_inicio?: Date;
@ApiProperty({ @ApiProperty({
description: 'Fecha de fin', description: 'Fecha de fin',
example: '2025-03-31T23:59:59', example: '2025-03-31T23:59:59',
required: false, required: false
}) })
@IsOptional()
@IsDateString() @IsDateString()
fecha_fin: Date; fecha_fin?: Date;
@ApiProperty({ @ApiProperty({
description: 'ID del tipo de cuestionario', description: 'ID del tipo de cuestionario',
example: 1, example: 1
}) })
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
id_tipo_cuestionario: number; 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({ @ApiProperty({
description: 'Nombre del evento asociado', description: 'Nombre del evento asociado',
example: 'Feria de la Sexualidad', example: 'Feria de la Sexualidad',
required: false, required: false
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
evento?: string; 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({ @ApiProperty({
description: 'Secciones del cuestionario', description: 'Secciones del cuestionario',
type: [CreateSeccionDto], type: [CreateSeccionDto],
required: false, required: false
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -109,8 +67,3 @@ export class CreateCuestionarioDto {
@Type(() => CreateSeccionDto) @Type(() => CreateSeccionDto)
secciones?: CreateSeccionDto[]; secciones?: CreateSeccionDto[];
} }
export class CreateCuestionarioEventoDto extends OmitType(
CreateCuestionarioDto,
['id_evento'] as const,
) {}
@@ -1,7 +0,0 @@
import { CreateEventoDto } from 'src/evento/dto/create-evento.dto';
import { CreateCuestionarioDto, CreateCuestionarioEventoDto } from './create-cuestionario.dto';
export interface CreateEventoWithCuestionarioDto {
evento: CreateEventoDto;
cuestionario: CreateCuestionarioEventoDto;
}
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { TipoPreguntaEnum } from '../../pregunta/dto/create-pregunta.dto';
// Ejemplo de respuesta de cuestionario completo // Ejemplo de respuesta de cuestionario completo
export const FormularioFeriaSchema = { export const FormularioFeriaSchema = {
@@ -36,6 +37,7 @@ export const FormularioFeriaSchema = {
contador_opcion: 2, contador_opcion: 2,
obligatoria: true, obligatoria: true,
id_tipo_pregunta: 1, id_tipo_pregunta: 1,
id_opcion_dependiente: null,
tipo_pregunta: { tipo_pregunta: {
id_tipo: 1, id_tipo: 1,
tipo_pregunta: 'Cerrada' tipo_pregunta: 'Cerrada'
@@ -71,6 +73,7 @@ export const FormularioFeriaSchema = {
contador_opcion: 0, contador_opcion: 0,
obligatoria: true, obligatoria: true,
id_tipo_pregunta: 2, id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'correo', // Validación de formato de correo electrónico validacion: 'correo', // Validación de formato de correo electrónico
tipo_pregunta: { tipo_pregunta: {
@@ -89,6 +92,7 @@ export const FormularioFeriaSchema = {
contador_opcion: 0, contador_opcion: 0,
obligatoria: false, obligatoria: false,
id_tipo_pregunta: 2, id_tipo_pregunta: 2,
id_opcion_dependiente: 90001, // Dependiente de la respuesta "Sí" en la pregunta anterior
limite: 250, // Límite de caracteres para respuestas de texto limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'cuenta_alumno', // Validación de formato de número de cuenta validacion: 'cuenta_alumno', // Validación de formato de número de cuenta
tipo_pregunta: { tipo_pregunta: {
@@ -119,6 +123,7 @@ export const FormularioFeriaSchema = {
contador_opcion: 5, contador_opcion: 5,
obligatoria: true, obligatoria: true,
id_tipo_pregunta: 3, id_tipo_pregunta: 3,
id_opcion_dependiente: null,
tipo_pregunta: { tipo_pregunta: {
id_tipo: 3, id_tipo: 3,
tipo_pregunta: 'Multiple' tipo_pregunta: 'Multiple'
@@ -181,6 +186,7 @@ export const FormularioFeriaSchema = {
contador_opcion: 0, contador_opcion: 0,
obligatoria: false, obligatoria: false,
id_tipo_pregunta: 2, id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: { tipo_pregunta: {
id_tipo: 2, id_tipo: 2,
@@ -1,17 +1,9 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity'; import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Evento } from 'src/evento/entities/evento.entity'; import { Evento } from 'src/evento/evento.entity';
import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity';
import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity'; import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity';
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne, JoinColumn } from 'typeorm';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
@Entity('cuestionario') @Entity('cuestionario')
export class Cuestionario { export class Cuestionario {
@@ -21,9 +13,6 @@ export class Cuestionario {
@Column({ type: 'text' }) @Column({ type: 'text' })
nombre_form: string; nombre_form: string;
@Column({ type: 'text', nullable: true })
banner: string;
@Column({ type: 'int' }) @Column({ type: 'int' })
contador_secciones: number; contador_secciones: number;
@@ -33,56 +22,29 @@ export class Cuestionario {
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
editable: boolean; editable: boolean;
@Column({ type: 'datetime' }) @Column({ type: 'datetime', nullable: true })
fecha_inicio: Date; fecha_fin?: Date;
@Column({ type: 'datetime' }) @Column({ type: 'datetime', nullable: true })
fecha_fin: Date; fecha_inicio?: Date;
@OneToMany(() => CuestionarioSeccion,(cuestionario_seccion) => cuestionario_seccion.id_cuestionario)
cuestionarioSeccion:CuestionarioSeccion[]
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
cupo_maximo?: number; id_cuestionario_original: number;
@Column({ type: 'int' }) @Column({ type: 'int' })
id_tipo_cuestionario: number; id_tipo_cuestionario: number;
@Column({ type: 'int', nullable: true }) @ManyToOne(()=> Cuestionario)
id_tipo_evento: number; cuestionarioOriginal: Cuestionario;
@Column({ type: 'int' }) @Column({ type: 'int', nullable: true })
id_evento: number; id_evento: number;
@Column({ type: 'text', nullable: true }) @ManyToOne(() => Evento)
mensaje_correo?: string;
// Relaciones
@ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true })
@JoinColumn({ name: 'id_evento' }) @JoinColumn({ name: 'id_evento' })
evento?: 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
@@ -1,165 +0,0 @@
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'},
],
},
],
},
],
},
},
];
@@ -0,0 +1,20 @@
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();
});
});
@@ -18,7 +18,17 @@ export class CuestionarioRespondidoController {
} }
@Post('submit') @Post('submit')
@CuestionarioRespondidoApiDocumentation.ApiSubmit @CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestas
@ApiBody({
type: SubmitRespuestasDto,
examples: {
comunidad: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.communityExample,
externos: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.externalExample
}
})
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse201
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse400
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse404
submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) { submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) {
return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto); return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto);
} }
@@ -1,81 +1,26 @@
import { import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common'; 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 { export class CuestionarioRespondidoApiDocumentation {
static ApiController = ApiTags('Cuestionario Respondido'); static ApiController = ApiTags('Cuestionario Respondido');
static ApiSubmit = applyDecorators( static ApiSubmitRespuestas = ApiOperation({
ApiOperation({ summary: 'Enviar respuestas a un cuestionario',
summary: 'Enviar respuestas a un cuestionario', description: `Registra las respuestas de un participante a un cuestionario. Crea un registro de participante si no existe uno con ese correo.
description: `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: ## Tipos de respuestas:
- 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.`, - **Preguntas abiertas**: Enviar el texto de la respuesta como string (máximo 250 caracteres)
}), - **Preguntas cerradas**: Enviar el ID de la opción como número
ApiBody({ - **Preguntas de selección múltiple**: Enviar un array de IDs de opciones como [número, número, ...]
type: SubmitRespuestasDto,
examples: { ### Importante
envio_respuestas: ejemploEnvioRespuestas,
}, Para preguntas cerradas y múltiples, se deben enviar los IDs de las opciones, NO los textos.
}), Esto evita conflictos cuando existen opciones con el mismo texto pero diferentes IDs.
);
Todas las respuestas de texto y el correo electrónico están limitados a 250 caracteres.`
});
static ApiSubmitResponse201 = ApiResponse({ static ApiSubmitResponse201 = ApiResponse({
status: 201, status: 201,
@@ -83,13 +28,10 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
schema: { schema: {
properties: { properties: {
success: { type: 'boolean', example: true }, success: { type: 'boolean', example: true },
message: { message: { type: 'string', example: 'Respuestas guardadas correctamente' },
type: 'string', cuestionarioRespondidoId: { type: 'number', example: 1 }
example: 'Respuestas guardadas correctamente', }
}, }
cuestionarioRespondidoId: { type: 'number', example: 1 },
},
},
}); });
static ApiSubmitResponse400 = ApiResponse({ static ApiSubmitResponse400 = ApiResponse({
@@ -98,13 +40,10 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
schema: { schema: {
properties: { properties: {
statusCode: { type: 'number', example: 400 }, statusCode: { type: 'number', example: 400 },
message: { message: { type: 'string', example: 'La opción con ID 999 no está asociada a la pregunta 101' },
type: 'string', error: { type: 'string', example: 'Bad Request' }
example: 'La opción con ID 999 no está asociada a la pregunta 101', }
}, }
error: { type: 'string', example: 'Bad Request' },
},
},
}); });
static ApiSubmitResponse404 = ApiResponse({ static ApiSubmitResponse404 = ApiResponse({
@@ -113,13 +52,10 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
schema: { schema: {
properties: { properties: {
statusCode: { type: 'number', example: 404 }, statusCode: { type: 'number', example: 404 },
message: { message: { type: 'string', example: 'Cuestionario con ID 999 no encontrado' },
type: 'string', error: { type: 'string', example: 'Not Found' }
example: 'Cuestionario con ID 999 no encontrado', }
}, }
error: { type: 'string', example: 'Not Found' },
},
},
}); });
static get ApiReporteRespuestas() { static get ApiReporteRespuestas() {
@@ -138,17 +74,16 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
- Cuestionario - Cuestionario
- Pregunta - Pregunta
- Respuesta - Respuesta
`, `
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
description: description: 'Reporte de respuestas descargado exitosamente como archivo CSV'
'Reporte de respuestas descargado exitosamente como archivo CSV',
}), }),
ApiResponse({ ApiResponse({
status: 404, status: 404,
description: 'Cuestionario no encontrado', description: 'Cuestionario no encontrado'
}), })
); );
} }
@@ -162,16 +97,14 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
{ id_pregunta: 101, valor: 3 }, // Opción "Si" (usando el ID de la opción) { 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: 102, valor: 'estudiante@acatlan.unam.mx' }, // Correo electrónico
{ id_pregunta: 103, valor: '123456789' }, // Numero de cuenta { id_pregunta: 103, valor: '123456789' }, // Numero de cuenta
{ id_pregunta: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción) { id_pregunta: 106, valor: 7 } // Opción "Prefiero no decirlo" (usando el ID de la opción)
], ],
fecha_envio: '2025-04-02T13:45:00', fecha_envio: '2025-04-02T13:45:00'
}, },
description: description: 'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.'
'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.',
}, },
externalExample: { externalExample: {
summary: summary: 'Ejemplo de respuesta para un participante externo con selección múltiple',
'Ejemplo de respuesta para un participante externo con selección múltiple',
value: { value: {
id_formulario: 1, id_formulario: 1,
correo: 'externo@ejemplo.com', correo: 'externo@ejemplo.com',
@@ -182,12 +115,11 @@ Además, si el cuestionario está ligado a un evento, se registra su participaci
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos { 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: 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: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción)
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' }, // Institución de procedencia { id_pregunta: 107, valor: 'UAM Azcapotzalco' } // Institución de procedencia
], ],
fecha_envio: '2025-04-02T13:45:00', fecha_envio: '2025-04-02T13:45:00'
}, },
description: description: 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.'
'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.', }
},
}; };
} }
@@ -5,8 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity'; import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity';
import { CuestionarioModule } from '../cuestionario/cuestionario.module'; import { CuestionarioModule } from '../cuestionario/cuestionario.module';
import { ParticipanteModule } from '../participante/participante.module'; import { ParticipanteModule } from '../participante/participante.module';
import { QrModule } from '../qr/qr.module'; import { Participante } from '../participante/participante.entity';
import { Participante } from '../participante/entities/participante.entity';
import { Cuestionario } from '../cuestionario/entities/cuestionario.entity'; import { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity'; import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity'; import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
@@ -14,7 +13,6 @@ import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidacionesModule } from '../validaciones/validaciones.module'; import { ValidacionesModule } from '../validaciones/validaciones.module';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [ imports: [
@@ -26,16 +24,14 @@ import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
RespuestaParticipanteAbierta, RespuestaParticipanteAbierta,
RespuestaParticipanteCerrada, RespuestaParticipanteCerrada,
PreguntaOpcion, PreguntaOpcion,
Opcion, Opcion
]), ]),
CuestionarioModule, CuestionarioModule,
ParticipanteModule, ParticipanteModule,
ValidacionesModule, ValidacionesModule
QrModule,
EventoUsuarioModule,
], ],
controllers: [CuestionarioRespondidoController], controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService], providers: [CuestionarioRespondidoService],
exports: [TypeOrmModule], exports: [TypeOrmModule]
}) })
export class CuestionarioRespondidoModule {} export class CuestionarioRespondidoModule {}
@@ -0,0 +1,18 @@
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();
});
});
File diff suppressed because it is too large Load Diff
@@ -8,14 +8,14 @@ import {
IsString, IsString,
ValidateNested, ValidateNested,
ValidateIf, ValidateIf,
MaxLength, MaxLength
} from 'class-validator'; } from 'class-validator';
import { Type, Transform } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
export class RespuestaDto { export class RespuestaDto {
@ApiProperty({ @ApiProperty({
description: 'ID de la pregunta respondida', description: 'ID de la pregunta respondida',
example: 101, example: 101
}) })
@IsNumber() @IsNumber()
id_pregunta: number; id_pregunta: number;
@@ -23,47 +23,40 @@ export class RespuestaDto {
@ApiProperty({ @ApiProperty({
description: 'Valor de la respuesta según tipo de pregunta', description: 'Valor de la respuesta según tipo de pregunta',
examples: [ examples: [
'Texto para pregunta abierta', // String para preguntas abiertas 'Texto para pregunta abierta', // String para preguntas abiertas
5, // Número para preguntas cerradas (ID de opción) 5, // Número para preguntas cerradas (ID de opción)
[2, 7, 9], // Array para preguntas múltiples (IDs de opciones) [2, 7, 9] // Array para preguntas múltiples (IDs de opciones)
], ],
oneOf: [ oneOf: [
{ { type: 'string', description: 'Texto para pregunta abierta (máximo 250 caracteres)' },
type: 'string',
description: 'Texto para pregunta abierta (máximo 250 caracteres)',
},
{ type: 'number', description: 'ID de opción para pregunta cerrada' }, { type: 'number', description: 'ID de opción para pregunta cerrada' },
{ {
type: 'array', type: 'array',
items: { type: 'number' }, items: { type: 'number' },
description: 'Array de IDs de opciones para pregunta multiple', description: 'Array de IDs de opciones para pregunta multiple'
}, }
], ]
})
@ValidateIf((o) => typeof o.valor === 'string')
@MaxLength(250, {
message: 'Las respuestas de texto no pueden exceder 250 caracteres',
}) })
@ValidateIf(o => typeof o.valor === 'string')
@MaxLength(250, { message: 'Las respuestas de texto no pueden exceder 250 caracteres' })
valor: string | number | number[]; valor: string | number | number[];
} }
export class SubmitRespuestasDto { export class SubmitRespuestasDto {
@ApiProperty({ @ApiProperty({
description: 'ID del cuestionario a responder', description: 'ID del formulario a responder',
example: 1, example: 1
}) })
@IsNumber() @IsNumber()
id_cuestionario: number; id_formulario: number;
@ApiProperty({ @ApiProperty({
description: 'Correo electrónico del participante', description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com', example: 'usuario@ejemplo.com'
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(250, { @MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' })
message: 'El correo electrónico no puede exceder 250 caracteres',
})
correo: string; correo: string;
@ApiProperty({ @ApiProperty({
@@ -72,17 +65,17 @@ export class SubmitRespuestasDto {
examples: [ examples: [
{ {
id_pregunta: 101, id_pregunta: 101,
valor: 'Texto para pregunta abierta', valor: 'Texto para pregunta abierta'
}, },
{ {
id_pregunta: 102, id_pregunta: 102,
valor: 5, valor: 5 // ID de la opción para pregunta cerrada simple
}, },
{ {
id_pregunta: 103, id_pregunta: 103,
valor: [2, 7, 9], valor: [2, 7, 9] // Array de IDs de opciones para pregunta de selección múltiple
}, }
], ]
}) })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@@ -91,7 +84,7 @@ export class SubmitRespuestasDto {
@ApiProperty({ @ApiProperty({
description: 'Fecha y hora del envío', description: 'Fecha y hora del envío',
example: '2025-04-02T13:45:00', example: '2025-04-02T13:45:00'
}) })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
@@ -3,7 +3,7 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColum
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.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 { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { Participante } from 'src/participante/entities/participante.entity'; import { Participante } from 'src/participante/participante.entity';
import { Opcion } from 'src/opcion/entities/opcion.entity'; import { Opcion } from 'src/opcion/entities/opcion.entity';
@Entity('cuestionario_respondido') @Entity('cuestionario_respondido')
@@ -10,6 +10,7 @@ export class CuestionarioSeccion {
@Column({ type: 'tinyint' }) @Column({ type: 'tinyint' })
posicion: number; posicion: number;
@ManyToOne(() => Cuestionario, (cuestionario) => cuestionario.cuestionarioSeccion) @ManyToOne(() => Cuestionario, (cuestionario) => cuestionario.cuestionarioSeccion)
cuestionario: Cuestionario; cuestionario: Cuestionario;
-80
View File
@@ -1,80 +0,0 @@
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,
});
+3 -1
View File
@@ -18,9 +18,11 @@ export const swaggerConfig = new DocumentBuilder()
La API permite crear cuestionarios complejos con secciones y diferentes tipos de preguntas. 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. Consulte la documentación de cada endpoint para ver ejemplos específicos.
`) `)
.addServer(process.env.SWAGGER_SERVER_URL || 'http://localhost:3000')
.addServer( 'http://localhost:3000')
.setVersion('1.0') .setVersion('1.0')
.addTag('Administradores')
.addTag('Cuestionarios') .addTag('Cuestionarios')
.addTag('Secciones') .addTag('Secciones')
.addTag('Preguntas') .addTag('Preguntas')
-307
View File
@@ -1,307 +0,0 @@
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
@@ -1,80 +0,0 @@
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
@@ -1,95 +0,0 @@
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
@@ -1,57 +0,0 @@
/* 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',
},
};
+10 -23
View File
@@ -1,30 +1,17 @@
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator'; import { IsString } from "class-validator";
import { Type } from 'class-transformer';
export class CreateEventoDto { export class CreateEventoDto {
@IsString() @IsString()
@IsNotEmpty({ message: 'El tipo de evento es obligatorio.' }) tipo_evento: string;
tipo_evento: string;
@IsString() @IsString()
@IsNotEmpty({ message: 'El nombre del evento es obligatorio.' }) nombre_evento: string;
nombre_evento: string;
@IsOptional() @IsString()
@IsNotEmpty({ message: 'La descripción del evento es obligatoria.' }) fecha_inicio: Date;
descripcion_evento: string;
@Type(() => Date) @IsString()
@IsDate({ message: 'La fecha de inicio debe ser una fecha válida.' }) fecha_fin: Date;
@IsNotEmpty({ message: 'La fecha de inicio es obligatoria.' })
fecha_inicio: Date;
@Type(() => Date) //agregar el id del administrador
@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
@@ -1,73 +0,0 @@
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[];
}
+4 -25
View File
@@ -1,27 +1,6 @@
import { IsOptional, IsString, IsNotEmpty, IsDate } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateEventoDto { export class UpdateEventoDto {
@IsOptional() tipo_evento?: string
@IsString() nombre_evento: string
@IsNotEmpty({ message: 'El tipo de evento no puede estar vacío si se proporciona' }) fecha_inicio?: Date
tipo_evento?: string; fecha_fin?: Date
@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
@@ -1,46 +0,0 @@
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;
}
+24 -149
View File
@@ -1,162 +1,37 @@
import { import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UnprocessableEntityException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { EventoService } from './evento.service'; import { EventoService } from './evento.service';
import { Evento } from './entities/evento.entity'; import { Evento } from './evento.entity';
import { CreateEventoDto } from './dto/create-evento.dto'; import { CreateEventoDto } from './dto/create-evento.dto';
import { UpdateEventoDto } from './dto/update.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') @Controller('evento')
@EventoApiDocumentation.ApiController
export class EventoController { export class EventoController {
constructor( constructor(private eventoService: EventoService) {}
private eventoService: EventoService,
private eventoUsuarioService: EventoUsuarioService,
) {}
@Post() @Get()
@EventoApiDocumentation.ApiCreate getEventos(): Promise<Evento[]> {
createEvento(@Body() newEvento: CreateEventoDto) { return this.eventoService.getEventos()
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.',
);
} }
return this.eventoService.asociarBanner(id, file.filename); @Get(':id')
} getEvento(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.getEvento(id)
}
@Get(':id') @Post()
getEvento(@Param('id', ParseIntPipe) id: number) { createEvento(@Body() newEvento: CreateEventoDto) {
return this.eventoService.getEvento(id); return this.eventoService.createEvento(newEvento)
} }
@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);
}
} }
+44
View File
@@ -0,0 +1,44 @@
import { Imagen } from "src/imagen/imagen.entity";
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[];
@OneToMany(() => Imagen, (imagen) => imagen.evento)
imagenes: Imagen[]
}
+5 -8
View File
@@ -1,17 +1,14 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { EventoService } from './evento.service'; import { EventoService } from './evento.service';
import { EventoController } from './evento.controller'; import { EventoController } from './evento.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Evento } from './entities/evento.entity'; import { Evento } from './evento.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; import { ImagenModule } from 'src/imagen/imagen.module';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([Evento]), forwardRef(() => ImagenModule)],
TypeOrmModule.forFeature([Evento]),
EventoUsuarioModule,
],
controllers: [EventoController], controllers: [EventoController],
providers: [EventoService], providers: [EventoService],
exports: [EventoService], exports: [EventoService, TypeOrmModule]
}) })
export class EventoModule {} export class EventoModule {}
+53 -253
View File
@@ -1,273 +1,73 @@
import { import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Evento } from './entities/evento.entity'; import { Evento } from './evento.entity';
import { CreateEventoDto } from './dto/create-evento.dto'; import { CreateEventoDto } from './dto/create-evento.dto';
import { UpdateEventoDto } from './dto/update.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() @Injectable()
export class EventoService { export class EventoService {
constructor( constructor(
@InjectRepository(Evento) private eventoRepository: Repository<Evento>, @InjectRepository(Evento) private eventoRepository: Repository<Evento>,
) {} ) {}
async createEvento(evento: CreateEventoDto) { async createEvento(evento: CreateEventoDto) {
const eventoFound = await this.eventoRepository.findOne({ const eventoFound = await this.eventoRepository.findOne({
where: { where: {
nombre_evento: evento.nombre_evento, nombre_evento: evento.nombre_evento,
tipo_evento: evento.tipo_evento, tipo_evento: evento.tipo_evento
}, }
}); })
if (eventoFound) if (eventoFound)
throw new HttpException( return new HttpException('Evento already exists', HttpStatus.CONFLICT)
'Ya existe un evento con el mismo nombre y tipo, puede agregar el periodo al nombre del evento para diferenciarlos',
HttpStatus.BAD_REQUEST,
);
const createEvento = this.eventoRepository.create(evento); const createEvento = this.eventoRepository.create(evento)
return this.eventoRepository.save(createEvento); return this.eventoRepository.save(createEvento)
} }
// evento.service.ts getEventos() {
return this.eventoRepository.find({
relations: ['participanteEventos', 'imagenes'] //participante
})
}
async asociarBanner(id: number, filename: string) { async getEvento(id_evento: number) {
const evento = await this.getEventoOrFail(id); const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento
},
relations: ['participanteEventos', 'imagenes'],
})
// Si ya tiene un banner anterior, eliminarlo if (!eventoFound)
if (evento.banner) { return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
const bannerPath = join(
__dirname, return eventoFound
'..', }
'..',
'uploads', async deleteEvento(id_evento: number) {
'banners', const result = await this.eventoRepository.delete({ id_evento })
evento.banner,
); if (result.affected === 0) {
if (existsSync(bannerPath)) { return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
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
} }
// Asociar el nuevo banner async updateEvento(id_evento: number, evento: UpdateEventoDto) {
evento.banner = filename; const eventoFound = await this.eventoRepository.findOne({
return this.eventoRepository.save(evento); where: {
} id_evento
}
})
getEventos() { if (!eventoFound)
return this.eventoRepository.find(); return new HttpException('Evento not found', HttpStatus.NOT_FOUND)
}
async getCuestionariosEvento(id_evento: number) { const updateEvento = Object.assign(eventoFound, evento)
const eventos = await this.eventoRepository.findOne({ return this.eventoRepository.save(updateEvento)
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,
};
}
} }
@@ -1,37 +0,0 @@
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;
}
@@ -1,17 +0,0 @@
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 {}
@@ -1,222 +0,0 @@
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),
};
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
@ApiSchema({ name: 'CreateImagenRequest' })
class CreateCatDto {}
export class CreateImagenDto {
/*
url: string
orden: number
tipo: string
evento: {
id_evento: number
} // si usas relaciones
evento: number
*/
@IsOptional()
@IsString()
url: string;
@Type(() => Number)
@IsInt()
orden: number;
@IsString()
tipo: string;
@Type(() => Number)
@IsInt() // tiene que ser el id del evento
evento: number;
// Campo extra para controlar lógica, no se guarda
@IsOptional()
@IsBoolean()
@ApiProperty({
type: Boolean
})
@Transform(({ value }) => value === 'true' || value === true)
force_orden?: boolean;
}
+17
View File
@@ -0,0 +1,17 @@
import { Type } from "class-transformer"
import { IsInt, IsOptional, IsString } from "class-validator"
export class UpdateImagenDto {
@IsString()
@IsOptional()
url?: string
@Type(() => Number)
@IsOptional()
@IsInt()
orden?: number
@IsOptional()
@IsString()
tipo?: string
}
+108
View File
@@ -0,0 +1,108 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpException, HttpStatus, Param, ParseIntPipe, Patch, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ImagenService, imageStorage } from './imagen.service';
import { UpdateImagenDto } from './dto/update.imagen.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { extname, join } from 'path';
import { diskStorage } from 'multer';
import { CreateImagenDto } from './dto/create-imagen.dto';
import { existsSync } from 'fs';
import { Response } from 'express';
import { ImagenApiDocumentation } from './imagen.documentation'; // Importar la documentación
@ImagenApiDocumentation.ApiController
@Controller('imagen')
export class ImagenController {
constructor(private imagenService: ImagenService) {}
@Get()
getImagenes() {
return this.imagenService.getImagenes()
}
@Get('evento/:id_evento')
@ImagenApiDocumentation.ApiGetImagenEvento // Aplicando la documentación al método
getImagenEvento(@Param('id_evento', ParseIntPipe) id_evento: number) {
return this.imagenService.getImagenEvento(id_evento)
}
@Get(':id')
@ImagenApiDocumentation.ApiGetOne
getImagen(@Param('id', ParseIntPipe) id_imagen: number) {
return this.imagenService.getImagen(id_imagen)
}
@Get('visual/:id')
@ImagenApiDocumentation.ApiGetVisual // Aplicando la documentación al método
async getVisualImagen(
@Param('id') id_imagen: number,
@Res() res: Response
) {
const url = await this.imagenService.getVisualImagen(id_imagen);
const rutaImagen = join(process.cwd(), url);
if (!existsSync(rutaImagen)) {
throw new HttpException('Imagen no encontrada', HttpStatus.NOT_FOUND);
}
return res.sendFile(rutaImagen);
}
@Post()
@ImagenApiDocumentation.ApiCreate
@UseInterceptors(FileInterceptor('imagen', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) => {
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = extname(file.originalname);
cb(null, `${uniqueName}${extension}`);
},
}),
}))
async subirImagen(@UploadedFile() file: Express.Multer.File, @Body() imagen: CreateImagenDto,) {
if (!file) {
throw new BadRequestException('No se ha subido ningún archivo');
}
if (!imagen.evento) {
throw new BadRequestException('Falta el id_evento en los datos');
}
const url = `/uploads/${file.filename}`;
return this.imagenService.crearImagen({
...imagen,
url,
evento: imagen.evento,
});
}
@Delete(':id')
@ImagenApiDocumentation.ApiRemove
deleteImagen(@Param('id', ParseIntPipe) id_imagen: number) {
return this.imagenService.deleteImagen(id_imagen)
}
@Patch(':id')
@ImagenApiDocumentation.ApiUpdate
@UseInterceptors(FileInterceptor('imagen', { storage: imageStorage })) // Interceptor para manejar la subida de archivo
async updateImagen(
@Param('id') id: number,
@UploadedFile() file: Express.Multer.File, // Recibe el archivo cargado
@Body() imagen: UpdateImagenDto,
) {
if (file) {
// Si se sube un nuevo archivo, actualiza la URL
imagen.url = `/uploads/${file.filename}`;
}
const newImagen = await this.imagenService.updateImagen(id, imagen);
return newImagen;
}
}
+210
View File
@@ -0,0 +1,210 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { applyDecorators, Get, HttpException, HttpStatus, Param, ParseIntPipe, Res } from '@nestjs/common';
import { join } from 'path';
import { existsSync } from 'fs';
export class ImagenApiDocumentation {
// Decoradores para toda la clase del controlador
static ApiController = ApiTags('Imágenes');
// Documentación para obtener una imagen visual por ID
static ApiGetVisual = applyDecorators(
ApiOperation({
summary: 'Obtener imagen visual por ID',
description: 'Retorna una imagen visual específica dada su ID.'
}),
ApiParam({
name: 'id',
description: 'ID de la imagen visual',
required: true,
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Imagen visual encontrada correctamente.',
content: {
'image/png': {
schema: { type: 'string', format: 'binary' }
}
}
}),
ApiResponse({ status: 404, description: 'Imagen no encontrada.' }),
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
);
// Documentación para obtener las imágenes asociadas a un evento
static ApiGetImagenEvento = applyDecorators(
ApiOperation({
summary: 'Obtener imágenes por evento',
description: 'Retorna una lista de imágenes asociadas a un evento específico.'
}),
ApiParam({
name: 'id_evento',
description: 'ID del evento',
required: true,
type: 'number',
example: 10
}),
ApiResponse({
status: 200,
description: 'Imágenes del evento obtenidas correctamente.',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_imagen: { type: 'number', example: 1 },
url: { type: 'string', example: '/uploads/imagen1.png' },
evento_id: { type: 'number', example: 10 }
}
}
}
}),
ApiResponse({ status: 404, description: 'Evento no encontrado.' }),
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
);
// Documentación para crear una nueva imagen
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Crear una nueva imagen',
description: 'Crea una nueva imagen asociada a un evento específico.'
}),
ApiBody({
description: 'Datos para crear una nueva imagen',
schema: {
type: 'object',
required: ['orden', 'tipo', 'evento'],
properties: {
url: { type: 'string', example: '/uploads/imagen1.png' },
orden: { type: 'number', example: 1 },
tipo: { type: 'string', example: 'cabezera' },
evento: { type: 'number', example: 5 }
}
}
}),
ApiResponse({
status: 201,
description: 'Imagen creada correctamente.',
schema: {
type: 'object',
properties: {
id_imagen: { type: 'number', example: 1 },
url: { type: 'string', example: '/uploads/imagen1.png' },
orden: { type: 'number', example: 1 },
tipo: { type: 'string', example: 'cabezera' },
evento: {
type: 'object',
properties: {
id_evento: { type: 'number', example: 1 }
}
}
}
}
}),
ApiResponse({ status: 400, description: 'Datos inválidos para crear la imagen.' }),
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
);
// Documentación para actualizar una imagen
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar una imagen',
description: 'Permite actualizar los datos de una imagen existente'
}),
ApiParam({
name: 'id',
description: 'ID de la imagen a actualizar',
required: true,
type: 'number',
example: 1
}),
ApiBody({
description: 'Datos a actualizar de la imagen',
schema: {
type: 'object',
properties: {
url: { type: 'string', example: '/uploads/1745549896986-789608318_updated.png' },
descripcion: { type: 'string', example: 'Nueva imagen de ejemplo para actualización' }
}
}
}),
ApiResponse({
status: 200,
description: 'Imagen actualizada correctamente',
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 400, description: 'Datos inválidos para la actualización de la imagen.' }),
ApiResponse({ status: 404, description: 'Imagen no encontrada.' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
);
// Documentación para obtener una imagen por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener una imagen por ID',
description: 'Permite obtener los detalles de una imagen a partir de su ID.'
}),
ApiParam({
name: 'id',
description: 'ID de la imagen',
required: true,
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Imagen encontrada correctamente',
schema: {
type: 'object',
properties: {
id_imagen: { type: 'number', example: 1 },
url: { type: 'string', example: '/uploads/1745549896986-789608318.png' },
orden: { type: 'number', example: '1'},
tipo: { type: 'string', example: 'Cabezera'},
//descripcion: { type: 'string', example: 'Imagen de muestra para la documentación' }
}
}
}),
ApiResponse({ status: 404, description: 'Imagen no encontrada' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
);
// Documentación para eliminar una imagen
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar una imagen',
description: 'Permite eliminar una imagen a partir de su ID.'
}),
ApiParam({
name: 'id',
description: 'ID de la imagen a eliminar',
required: true,
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Imagen eliminada correctamente',
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 404, description: 'Imagen no encontrada' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Type } from "class-transformer";
import { IsInt } from "class-validator";
import { Evento } from "src/evento/evento.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Imagen {
@PrimaryGeneratedColumn()
id_imagen: number
@Column()
url?: string
@Column()
@Type(() => Number)
orden: number
@Column()
tipo: string
@ManyToOne(() => Evento, (evento) => evento.imagenes, { onDelete: 'CASCADE' })
@IsInt()
@Type(() => Number)
evento: Evento;
}
+14
View File
@@ -0,0 +1,14 @@
import { forwardRef, Module } from '@nestjs/common';
import { ImagenService } from './imagen.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Imagen } from './imagen.entity';
import { EventoModule } from 'src/evento/evento.module';
import { ImagenController } from './imagen.controller';
@Module({
imports: [TypeOrmModule.forFeature([Imagen]), forwardRef(() => EventoModule)], //forwardRef(() => EventoModule
controllers: [ImagenController],
providers: [ImagenService],
exports: [ImagenService]
})
export class ImagenModule {}
+169
View File
@@ -0,0 +1,169 @@
import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Imagen } from './imagen.entity';
import { Repository } from 'typeorm';
import { Evento } from 'src/evento/evento.entity';
import { CreateImagenDto } from './dto/create-imagen.dto';
import { UpdateImagenDto } from './dto/update.imagen.dto';
import { extname } from 'path';
import { diskStorage } from 'multer';
@Injectable()
export class ImagenService {
constructor(
@InjectRepository(Imagen) private imagenRepository: Repository<Imagen>,
@InjectRepository(Evento) private eventoRepository: Repository<Evento>
) {}
async getUltimoOrden(id_evento: number): Promise<number> {
const [ultimo] = await this.imagenRepository.find({
where: {
evento: { id_evento }, // Filtrar por evento
},
order: {
orden: 'DESC',
},
take: 1,
});
return ultimo?.orden ?? 0;
}
async crearImagen(imagen: CreateImagenDto): Promise<Imagen> {
const evento = await this.eventoRepository.findOneBy({ id_evento: imagen.evento });
if (!evento) {
throw new NotFoundException('Evento no encontrado');
}
const ordenExiste = await this.imagenRepository.findOneBy({
orden: imagen.orden,
evento: { id_evento: imagen.evento },
});
if (ordenExiste) {
if (imagen.force_orden === false || imagen.force_orden === undefined) {
throw new HttpException('Este orden ya existe', HttpStatus.CONFLICT)
}
}
if (imagen.force_orden === true) {
const imagenExistente = await this.imagenRepository.findOne({
where: {
orden: imagen.orden,
evento: { id_evento: imagen.evento },
},
relations: ['evento'],
});
if (imagenExistente) {
const ultimoOrden = await this.getUltimoOrden(imagen.evento);
imagenExistente.orden = ultimoOrden + 1;
await this.imagenRepository.save(imagenExistente); // Guardar imagen existente con nuevo orden
}
}
// Crear y guardar nueva imagen
const nuevaImagen = this.imagenRepository.create({
...imagen,
evento: { id_evento: imagen.evento }, // para la relación ManyToOne
});
return this.imagenRepository.save(nuevaImagen);
}
getImagenes(): Promise<Imagen[]> {
return this.imagenRepository.find()
}
async getImagen(id_imagen: number) {
const imagenFound = await this.imagenRepository.findOne({
where: {
id_imagen
}
})
if (!imagenFound) {
return new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
}
return imagenFound
}
async getImagenEvento(id_evento: number) {
const imagenEventoFound = await this.eventoRepository.findOne({
where: {
id_evento
}
})
if (!imagenEventoFound) {
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
}
return this.imagenRepository.find({
where: {
evento: {
id_evento
}
}
})
}
async getVisualImagen(id_imagen: number): Promise<string> {
const imagenFound = await this.imagenRepository.findOne({
where: {
id_imagen
},
});
if (!imagenFound || !imagenFound.url) {
throw new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
}
return imagenFound.url;
}
async deleteImagen(id_imagen: number) {
const result = await this.imagenRepository.delete({ id_imagen })
if (result.affected === 0) {
return new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
}
return result
}
async updateImagen(id_imagen: number, imagen: UpdateImagenDto) {
const imagenFound = await this.imagenRepository.findOne({
where: {
id_imagen
}
});
if (!imagenFound) {
throw new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
}
if (imagen.url) {
imagenFound.url = imagen.url;
}
const updatedImagen = Object.assign(imagenFound, imagen);
return this.imagenRepository.save(updatedImagen);
}
}
export const imageStorage = diskStorage({
destination: './uploads', // Carpeta donde se almacenarán las imágenes
filename: (req, file, callback) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // Genera un nombre único
callback(null, uniqueSuffix + extname(file.originalname)); // Usa la extensión original del archivo
},
});
+46 -29
View File
@@ -1,59 +1,76 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; 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 { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path'; import { join, resolve } from 'path';
async function bootstrap() { async function bootstrap() {
//const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
// Configuración de validación global // Configuración de validación global
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, // Elimina propiedades no decoradas 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 transform: true, // Transforma los datos recibidos al tipo definido en el DTO
}),
);
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
index: false, })
prefix: '/', );
});
// CORS configuration // CORS configuration
app.enableCors({ app.enableCors({
origin: '*', origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization'],
}); });
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.
Flujo de creación: // Verifica que la carpeta uploads esté correctamente ubicada
const uploadsPath = resolve(__dirname, '..', 'uploads');
1. Primero, se debe crear un evento usando **POST /evento**. // Configurar los archivos estáticos para que se sirvan desde /uploads
2. Luego, se crea un formulario y se relaciona con el evento usando **POST /cuestionario**. app.useStaticAssets(uploadsPath, {
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.`, prefix: '/uploads', // Esto asegura que los archivos sean accesibles en http://localhost:4200/uploads
) });
.setVersion('1.0')
.addBearerAuth()
.addTag('Evento')
.addTag('Cuestionario')
.addTag('Cuestionario Respondido')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document); // Swagger setup
DocsModule.setupSwagger(app);
// 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');
}
// Start the server // Start the server
await app.listen(configService.get<number>('API_PORT') || 4200); await app.listen(process.env.PORT ?? 4200);
console.log('\nAPI en ejecución en http://localhost:4200'); console.log('API en ejecución en http://localhost:4200');
console.log('Swagger disponible en http://localhost:4200/api-docs'); console.log('Swagger disponible en http://localhost:4200/api-docs');
} }
bootstrap(); bootstrap();
+4 -2
View File
@@ -1,5 +1,5 @@
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from "src/pregunta_opcion/entities/pregunta_opcion.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity() @Entity()
export class Opcion { export class Opcion {
@@ -11,4 +11,6 @@ export class Opcion {
@OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.opcion) @OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.opcion)
preguntasOpciones: PreguntaOpcion[]; preguntasOpciones: PreguntaOpcion[];
} }
@@ -10,4 +10,13 @@ export class CreateParticipanteDto {
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' }) @IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' }) @IsNotEmpty({ message: 'El correo electrónico es requerido' })
correo: string; correo: string;
@ApiProperty({
description: 'ID del tipo de usuario',
example: 1,
required: true
})
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
@IsNotEmpty({ message: 'El ID del tipo de usuario es requerido' })
id_tipo_user: number;
} }
@@ -1,31 +0,0 @@
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[];
}
+1 -1
View File
@@ -1,5 +1,5 @@
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 { Participante } from './entities/participante.entity'; import { Participante } from './participante.entity';
import { ParticipanteService } from './participante.service'; import { ParticipanteService } from './participante.service';
import { CreateParticipanteDto } from './dto/create-participante.dto'; import { CreateParticipanteDto } from './dto/create-participante.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto'; import { UpdateParticipanteDto } from './dto/update.participante.dto';
+49
View File
@@ -0,0 +1,49 @@
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 { ParticipanteService } from './participante.service';
import { ParticipanteController } from './participante.controller'; import { ParticipanteController } from './participante.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Participante } from './entities/participante.entity'; import { Participante } from './participante.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Participante])], imports: [TypeOrmModule.forFeature([Participante])],
+99 -155
View File
@@ -1,180 +1,124 @@
import { import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Participante } from './entities/participante.entity'; import { Participante } from './participante.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateParticipanteDto } from './dto/create-participante.dto'; import { CreateParticipanteDto } from './dto/create-participante.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto'; import { UpdateParticipanteDto } from './dto/update.participante.dto';
@Injectable() @Injectable()
export class ParticipanteService { export class ParticipanteService {
constructor( constructor(
@InjectRepository(Participante) @InjectRepository(Participante) private participanteRepository: Repository<Participante>
private participanteRepository: Repository<Participante>, ) {}
) {}
/** /**
* Crea un nuevo participante * Crea un nuevo participante
* @param participante Datos del participante a crear * @param participante Datos del participante a crear
* @returns El participante creado * @returns El participante creado
* @throws ConflictException si ya existe un participante con el mismo correo * @throws ConflictException si ya existe un participante con el mismo correo
*/ */
async createParticipante( async createParticipante(participante: CreateParticipanteDto): Promise<Participante> {
participante: CreateParticipanteDto, // Verificar si ya existe un participante con el mismo correo
): Promise<Participante> { const participanteFound = await this.participanteRepository.findOne({
// Verificar si ya existe un participante con el mismo correo where: {
const participanteFound = await this.participanteRepository.findOne({ correo: participante.correo
where: { }
correo: participante.correo, });
},
});
if (participanteFound) { if (participanteFound) {
throw new ConflictException( throw new ConflictException(`Ya existe un participante con el correo ${participante.correo}`);
`Ya existe un participante con el correo ${participante.correo}`, }
);
const nuevoParticipante = this.participanteRepository.create(participante);
return this.participanteRepository.save(nuevoParticipante);
} }
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[]> {
* Obtiene todos los participantes return this.participanteRepository.find({
* @returns Lista de participantes con sus relaciones relations: ['tipo_user', 'participanteEventos']
*/ });
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`,
);
} }
return participanteFound; /**
} * 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']
});
async getParticipanteByCorreo(correo: string): Promise<Participante | null> { if (!participanteFound) {
const participanteFound = await this.participanteRepository.findOne({ throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
where: { }
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
return participanteFound;
}
async getParticipanteByCorreoOrFail(correo: string): Promise<Participante> { return participanteFound;
const participanteFound = await this.participanteRepository.findOne({
where: {
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
if (!participanteFound) {
throw new NotFoundException(
`Participante con correo ${correo} no encontrado`,
);
} }
return participanteFound; /**
} * Elimina un participante por su ID
* @param id_participante ID del participante a eliminar
* @returns Resultado de la eliminación
* @throws NotFoundException si no se encuentra el participante
*/
async deleteParticipante(id_participante: number) {
const result = await this.participanteRepository.delete({ id_participante });
/** if (result.affected === 0) {
* Elimina un participante por su ID throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
* @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,
});
if (result.affected === 0) { return {
throw new NotFoundException( success: true,
`Participante con ID ${id_participante} no encontrado`, message: `Participante con ID ${id_participante} eliminado correctamente`,
); affected: result.affected
};
} }
return { /**
success: true, * Actualiza los datos de un participante
message: `Participante con ID ${id_participante} eliminado correctamente`, * @param id_participante ID del participante a actualizar
affected: result.affected, * @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
}
});
/** if (!participanteFound) {
* Actualiza los datos de un participante throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
* @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,
},
});
if (!participanteFound) { // Si se está actualizando el correo, verificar que no exista otro participante con ese correo
throw new NotFoundException( if (participante.correo && participante.correo !== participanteFound.correo) {
`Participante con ID ${id_participante} no encontrado`, 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);
} }
// 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);
}
} }
@@ -1,12 +0,0 @@
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;
}
@@ -1,40 +0,0 @@
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,355 +1,186 @@
import { import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ParticipanteEventoService } from './participante_evento.service'; import { ParticipanteEventoService } from './participante_evento.service';
import { ParticipanteEvento } from './entities/participante_evento.entity'; import { ParticipanteEvento } from './participante_evento.entity';
import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto'; import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto';
import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto'; import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto';
import { RegistrarAsistenciaQrDto } from './dto/registrar-asistencia-qr.dto'; import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiResponse,
ApiBody,
} from '@nestjs/swagger';
@ApiTags('Participantes-Eventos') @ApiTags('Participantes-Eventos')
@Controller('participante-evento') @Controller('participante-evento')
export class ParticipanteEventoController { export class ParticipanteEventoController {
constructor(private participanteEventoService: ParticipanteEventoService) {}
@ApiOperation({ summary: 'Obtener participantes por evento' }) constructor(private participanteEventoService: ParticipanteEventoService) {}
@ApiParam({
name: 'id_cuestionario',
description: 'ID del evento',
type: 'number',
})
@ApiResponse({
status: 200,
description: 'Lista de participantes que están registrados en el evento',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
participante: {
type: 'object',
properties: {
id_participante: { type: 'number' },
correo: { type: 'string' },
id_tipo_user: { type: 'number' },
},
},
},
},
},
})
@ApiResponse({ status: 404, description: 'Evento no encontrado' })
@Get('evento/:id_cuestionario')
getParticipantesPorEvento(
@Param('id_cuestionario', ParseIntPipe) id_cuestionario: number,
) {
return this.participanteEventoService.getParticipantesPorCuestionario(
id_cuestionario,
);
}
@ApiOperation({ summary: 'Obtener eventos por participante' }) @ApiOperation({ summary: 'Obtener participantes por evento' })
@ApiParam({ @ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
name: 'idParticipante', @ApiResponse({
description: 'ID del participante', status: 200,
type: 'number', description: 'Lista de participantes que están registrados en el evento',
}) schema: {
@ApiResponse({ type: 'array',
status: 200, items: {
description: 'Lista de eventos en los que está registrado el participante',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
evento: {
type: 'object',
properties: {
id_evento: { type: 'number' },
nombre_evento: { type: 'string' },
tipo_evento: { type: 'string' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' },
},
},
},
},
},
})
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
@Get('participante/:idParticipante')
getEventosPorParticipante(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
) {
return this.participanteEventoService.getEventosPorParticipante(
idParticipante,
);
}
@ApiOperation({
summary: 'Obtener un registro específico por IDs de participante y evento',
})
@ApiParam({
name: 'idParticipante',
description: 'ID del participante',
type: 'number',
})
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':idParticipante/:idEvento')
getParticipanteEventoEspecifico(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number,
) {
return this.participanteEventoService.getParticipanteEventoEspecifico(
idParticipante,
idEvento,
);
}
@ApiOperation({ summary: 'Obtener un registro participante-evento por ID' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':id')
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.getParticipanteEvento(id);
}
@ApiOperation({
summary: 'Obtener todos los registros de participante-evento',
})
@ApiResponse({
status: 200,
description:
'Lista de todos los registros participante-evento con sus relaciones',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
},
},
},
})
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento();
}
@ApiOperation({ summary: 'Registrar un participante en un evento' })
@ApiResponse({
status: 201,
description: 'Participante registrado en el evento correctamente',
})
@ApiResponse({
status: 409,
description: 'El participante ya está registrado en este evento',
})
@Post()
createParticipanteEvento(
@Body() newParticipanteEvento: CreateParticipanteEventoDto,
) {
return this.participanteEventoService.createParticipanteEvento(
newParticipanteEvento,
);
}
@ApiOperation({
summary: 'Registrar asistencia de un participante a un evento',
})
@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', type: 'object',
properties: { properties: {
participante: { type: 'string' }, id_participante_evento: { type: 'number' },
fecha_asistencia: { type: 'string', format: 'date-time' }, id_participante: { type: 'number' },
id_evento: { type: 'number' }, id_evento: { type: 'number' },
id_cuestionario: { 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: {
@ApiResponse({ id_participante: { type: 'number' },
status: 400, correo: { type: 'string' },
description: 'Token QR inválido o expirado', id_tipo_user: { type: 'number' }
}) }
@ApiResponse({ }
status: 404, }
description: 'Participante no encontrado o no registrado en el evento', }
}) }
@Post('asistencia-qr') })
registrarAsistenciaConToken(@Body() body: { qr_token: string }) { @ApiResponse({ status: 404, description: 'Evento no encontrado' })
return this.participanteEventoService.registrarAsistenciaConToken( @Get('evento/:idEvento')
body.qr_token, getParticipantesPorEvento(@Param('idEvento', ParseIntPipe) idEvento: number) {
); return this.participanteEventoService.getParticipantesPorEvento(idEvento);
} }
@ApiOperation({ summary: 'Eliminar un registro participante-evento' }) @ApiOperation({ summary: 'Obtener eventos por participante' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' }) @ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Registro eliminado correctamente', description: 'Lista de eventos en los que está registrado el participante',
}) schema: {
@ApiResponse({ status: 404, description: 'Registro no encontrado' }) type: 'array',
@Delete(':id') items: {
deleteParticipanteEvento(@Param('id', ParseIntPipe) id: number) { type: 'object',
return this.participanteEventoService.deleteParticipanteEvento(id); properties: {
} id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
evento: {
type: 'object',
properties: {
id_evento: { type: 'number' },
nombre_evento: { type: 'string' },
tipo_evento: { type: 'string' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' }
}
}
}
}
}
})
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
@Get('participante/:idParticipante')
getEventosPorParticipante(@Param('idParticipante', ParseIntPipe) idParticipante: number) {
return this.participanteEventoService.getEventosPorParticipante(idParticipante);
}
@ApiOperation({ summary: 'Actualizar un registro participante-evento' }) @ApiOperation({ summary: 'Obtener un registro específico por IDs de participante y evento' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' }) @ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiResponse({ @ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
status: 200, @ApiResponse({
description: 'Registro actualizado correctamente', status: 200,
}) description: 'Registro participante-evento obtenido correctamente'
@ApiResponse({ status: 404, description: 'Registro no encontrado' }) })
@Patch(':id') @ApiResponse({ status: 404, description: 'Registro no encontrado' })
updateParticipanteEvento( @Get(':idParticipante/:idEvento')
@Param('id', ParseIntPipe) id_participante: number, getParticipanteEventoEspecifico(
id_evento: number, @Param('idParticipante', ParseIntPipe) idParticipante: number,
@Body() participanteEvento: UpdateParticipanteEventoDto, @Param('idEvento', ParseIntPipe) idEvento: number
) { ) {
return this.participanteEventoService.updateParticipanteEvento( return this.participanteEventoService.getParticipanteEventoEspecifico(idParticipante, idEvento)
id_participante, }
id_evento,
participanteEvento, @ApiOperation({ summary: 'Obtener un registro participante-evento por ID' })
); @ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
} @ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':id')
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.getParticipanteEvento(id)
}
@ApiOperation({ summary: 'Obtener todos los registros de participante-evento' })
@ApiResponse({
status: 200,
description: 'Lista de todos los registros participante-evento con sus relaciones',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true }
}
}
}
})
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento()
}
@ApiOperation({ summary: 'Registrar un participante en un evento' })
@ApiResponse({
status: 201,
description: 'Participante registrado en el evento correctamente'
})
@ApiResponse({ status: 409, description: 'El participante ya está registrado en este evento' })
@Post()
createParticipanteEvento(@Body() newParticipanteEvento: CreateParticipanteEventoDto) {
return this.participanteEventoService.createParticipanteEvento(newParticipanteEvento)
}
@ApiOperation({ summary: 'Registrar asistencia de un participante a un evento' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Asistencia registrada correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Post('asistencia/:idParticipante/:idEvento')
registrarAsistencia(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number
) {
return this.participanteEventoService.registrarAsistencia(idParticipante, idEvento)
}
@ApiOperation({ summary: 'Eliminar un registro participante-evento' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro eliminado correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Delete(':id')
deleteParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.deleteParticipanteEvento(id)
}
@ApiOperation({ summary: '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)
}
} }
@@ -0,0 +1,41 @@
import { Evento } from "src/evento/evento.entity";
import { Participante } from "src/participante/participante.entity";
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class ParticipanteEvento {
@PrimaryColumn()
id_participante: number
@PrimaryColumn()
id_evento: number
@Column()
fecha_inscripcion: Date
@Column()
estatus: boolean
@Column({ nullable: true, type: 'datetime' })
fecha_asistencia: Date
/*
@OneToOne(() => Qr, (qr) => qr.participanteEvento)
qr: Qr;
@OneToMany(() => ParticipanteEvento, (pe) => pe.evento)
participantes: ParticipanteEvento[];
@ManyToOne(() => Evento, (evento) => evento.participantes)
@JoinColumn({ name: "id_evento" })
evento: Evento;
*/
@ManyToOne(() => Participante, participante => participante.participanteEventos)
@JoinColumn({ name: "id_participante" })
participante: Participante;
@ManyToOne(() => Evento, evento => evento.participanteEventos)
@JoinColumn({ name: "id_evento" })
evento: Evento;
}
@@ -2,21 +2,12 @@ import { Module } from '@nestjs/common';
import { ParticipanteEventoService } from './participante_evento.service'; import { ParticipanteEventoService } from './participante_evento.service';
import { ParticipanteEventoController } from './participante_evento.controller'; import { ParticipanteEventoController } from './participante_evento.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ParticipanteEvento } from './entities/participante_evento.entity'; import { ParticipanteEvento } from './participante_evento.entity';
import { Evento } from 'src/evento/entities/evento.entity'; import { Evento } from 'src/evento/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity'; import { Participante } from 'src/participante/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({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante])],
TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante, Cuestionario, EventoUsuario, RespuestaParticipanteAbierta]),
CuestionarioModule,
QrModule,
],
controllers: [ParticipanteEventoController], controllers: [ParticipanteEventoController],
providers: [ParticipanteEventoService], providers: [ParticipanteEventoService],
exports: [ParticipanteEventoService], exports: [ParticipanteEventoService],
@@ -1,335 +1,160 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ParticipanteEvento } from './entities/participante_evento.entity'; import { ParticipanteEvento } from './participante_evento.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto'; import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto';
import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto'; import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto';
import { Evento } from 'src/evento/entities/evento.entity'; import { Evento } from 'src/evento/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity'; import { Participante } from 'src/participante/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() @Injectable()
export class ParticipanteEventoService { 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,
) {}
async createParticipanteEvento( constructor(
participanteEvento: CreateParticipanteEventoDto, @InjectRepository(ParticipanteEvento) private participanteEventoRepository: Repository<ParticipanteEvento>,
) { @InjectRepository(Evento) private eventoRepository: Repository<Evento>,
const participante_eventoFound = @InjectRepository(Participante) private participanteRepository: Repository<Participante>
await this.participanteEventoRepository.findOne({ ) {}
where: {
id_participante: participanteEvento.id_participante,
id_cuestionario: participanteEvento.id_evento,
},
});
if (participante_eventoFound) { async createParticipanteEvento(participanteEvento: CreateParticipanteEventoDto) {
return new HttpException(
'Participante already exists in this event',
HttpStatus.CONFLICT,
);
}
return this.participanteEventoRepository.save(participanteEvento); const participante_eventoFound = await this.participanteEventoRepository.findOne({
}
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);
}
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: { where: {
cuestionarioRespondido: { id_participante: participanteEvento.id_participante,
participante: { id_participante }, id_evento: participanteEvento.id_evento
cuestionario: { id_cuestionario }, }
}, })
pregunta: { validacion: TiposValidacion.CUENTA_ALUMNO },
},
relations: ['pregunta', 'cuestionarioRespondido'],
})) ??
(await this.respuestaAbiertaRepository.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante },
cuestionario: { id_cuestionario },
},
pregunta: { validacion: TiposValidacion.RFC },
},
relations: ['pregunta', 'cuestionarioRespondido'],
}));
if (respuesta) { if (participante_eventoFound) {
const identificador = respuesta.respuesta.trim().toUpperCase(); return new HttpException('Participante already exists in this event', HttpStatus.CONFLICT)
const eventoUsuario = await this.eventoUsuarioRepository.findOne({
where: { id_evento: cuestionario.id_evento, identificador },
});
contador = eventoUsuario?.contador ?? null;
} }
}
return this.participanteEventoRepository.save(participanteEvento)
} }
return { ...resultado, contador }; getParticipantesEvento() {
} return this.participanteEventoRepository.find({
relations: ['evento', 'participante']
/** })
* 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 async getParticipanteEvento(id_participante: number) {
const participanteEvento = await this.participanteEventoRepository.findOne({ const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: { where: {
id_participante, id_participante
id_cuestionario, },
}, relations: ['evento', 'participante']
relations: ['participante'], })
});
if (!participanteEvento) { if (!participante_eventoFound) {
throw new HttpException( return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
'El participante no está registrado en este evento', }
HttpStatus.NOT_FOUND,
); return participante_eventoFound
} }
// Verificar si ya había registrado asistencia async getParticipanteEventoEspecifico(id_participante: number, id_evento: number) {
if (participanteEvento.asistio && participanteEvento.fecha_asistencia) { const participante_eventoFound = await this.participanteEventoRepository.findOne({
return { where: {
success: false, id_participante,
message: id_evento
'El participante ya había registrado su asistencia previamente', },
data: { relations: ['evento', 'participante']
participante: participante.correo, })
fecha_asistencia_previa: participanteEvento.fecha_asistencia,
fecha_actual: new Date(), if (!participante_eventoFound) {
contador, return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}, }
};
return participante_eventoFound
} }
// Registrar la asistencia /**
participanteEvento.fecha_asistencia = new Date(); * Obtiene todos los participantes de un evento específico
participanteEvento.asistio = true; * @param id_evento ID del evento
* @returns Lista de registros participante-evento con información del participante
*/
async getParticipantesPorEvento(id_evento: number) {
// Verificar si el evento existe
const eventoExists = await this.eventoRepository.findOne({
where: { id_evento }
});
const resultado = if (!eventoExists) {
await this.participanteEventoRepository.save(participanteEvento); throw new HttpException(`Evento con ID ${id_evento} no encontrado`, HttpStatus.NOT_FOUND);
}
return { // Obtener todos los registros participante-evento para este evento
success: true, const participantesEvento = await this.participanteEventoRepository.find({
message: 'Asistencia registrada exitosamente', where: { id_evento },
data: { relations: ['participante']
participante: participante.correo, });
fecha_asistencia: resultado.fecha_asistencia,
id_evento,
id_cuestionario,
contador,
},
};
}
async validToken(qrToken: string) { return participantesEvento;
// Validar el token QR
const tokenData = this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) {
return null;
} }
return tokenData /**
} * 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_evento
}
})
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
}
return this.participanteEventoRepository.save(participante_evento)
}
async registrarAsistencia(id_participante: number, id_evento: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
}
});
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
participante_eventoFound.fecha_asistencia = new Date();
return this.participanteEventoRepository.save(participante_eventoFound);
}
} }
+20 -7
View File
@@ -1,9 +1,12 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, 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 { ApiProperty } from '@nestjs/swagger';
import { TiposValidacion } from '../entities/pregunta.entity';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
export enum TipoPreguntaEnum {
CERRADA = 'Cerrada',
ABIERTA = 'Abierta',
MULTIPLE = 'Multiple'
}
export class OpcionDto { export class OpcionDto {
@ApiProperty({ @ApiProperty({
@@ -46,18 +49,19 @@ export class CreatePreguntaDto {
description: 'Tipo de pregunta', description: 'Tipo de pregunta',
enum: Object.values(TipoPreguntaEnum), enum: Object.values(TipoPreguntaEnum),
enumName: 'TipoPreguntaEnum', enumName: 'TipoPreguntaEnum',
example: TipoPreguntaEnum.CERRADA,
examples: { examples: {
'cerrada': { 'cerrada': {
summary: 'Pregunta de opción única', summary: 'Pregunta de opción única',
value: TipoPreguntaEnum.Cerrada value: TipoPreguntaEnum.CERRADA
}, },
'abierta': { 'abierta': {
summary: 'Pregunta de texto libre', summary: 'Pregunta de texto libre',
value: TipoPreguntaEnum.AbiertaParrafo value: TipoPreguntaEnum.ABIERTA
}, },
'multiple': { 'multiple': {
summary: 'Pregunta de selección múltiple', summary: 'Pregunta de selección múltiple',
value: TipoPreguntaEnum.Multiple value: TipoPreguntaEnum.MULTIPLE
} }
} }
}) })
@@ -65,7 +69,7 @@ export class CreatePreguntaDto {
@IsEnum(TipoPreguntaEnum, { @IsEnum(TipoPreguntaEnum, {
message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple' message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple'
}) })
tipo: TipoPreguntaEnum; tipo: string;
@ApiProperty({ @ApiProperty({
description: 'Contador de opciones (se calcula automáticamente)', description: 'Contador de opciones (se calcula automáticamente)',
@@ -75,6 +79,15 @@ export class CreatePreguntaDto {
@IsNumber() @IsNumber()
contador_opcion?: number; contador_opcion?: number;
@ApiProperty({
description: 'ID de la opción de la que depende esta pregunta (para preguntas condicionadas)',
required: false,
example: null
})
@IsOptional()
@IsNumber()
id_opcion_dependiente?: number;
@ApiProperty({ @ApiProperty({
description: 'Tipo de validación para la respuesta', description: 'Tipo de validación para la respuesta',
required: false, required: false,
@@ -83,7 +96,7 @@ export class CreatePreguntaDto {
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
validacion?: TiposValidacion; validacion?: string;
@ApiProperty({ @ApiProperty({
description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple', description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple',
+9 -36
View File
@@ -1,33 +1,8 @@
import { IsEnum, IsOptional } from 'class-validator';
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity';
import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity'; import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity';
import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
import { import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
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() @Entity()
export class Pregunta { export class Pregunta {
@@ -43,24 +18,22 @@ export class Pregunta {
@Column({ default: false }) @Column({ default: false })
obligatoria: boolean; obligatoria: boolean;
@ManyToOne(() => TipoPregunta, (tipo) => tipo.preguntas) @ManyToOne(() => TipoPregunta, tipo => tipo.preguntas)
@JoinColumn({ name: 'id_tipo_pregunta' }) @JoinColumn({ name: 'id_tipo_pregunta' })
tipoPregunta: TipoPregunta; tipoPregunta: TipoPregunta;
@Column() @Column()
id_tipo_pregunta: number; id_tipo_pregunta: number;
@Column({ type: 'enum', enum: TiposValidacion, nullable: true }) @Column({ type: 'int', nullable: true })
@IsOptional() id_opcion_dependiente?: number;
@IsEnum(TiposValidacion, { message: 'Validación no permitida' })
validacion?: TiposValidacion;
@OneToMany( @Column({ nullable: true })
() => SeccionPregunta, validacion?: string;
(seccionPregunta) => seccionPregunta.pregunta,
) @OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta)
seccionPreguntas: SeccionPregunta[]; seccionPreguntas: SeccionPregunta[];
@OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.pregunta) @OneToMany(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.pregunta)
opciones: PreguntaOpcion[]; opciones: PreguntaOpcion[];
} }
+20
View File
@@ -0,0 +1,20 @@
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();
});
});
+67 -74
View File
@@ -1,12 +1,6 @@
import { import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common'; import { applyDecorators } from '@nestjs/common';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; import { TipoPreguntaEnum } from './dto/create-pregunta.dto';
export class PreguntaApiDocumentation { export class PreguntaApiDocumentation {
// Decoradores para toda la clase del controlador // Decoradores para toda la clase del controlador
@@ -16,7 +10,7 @@ export class PreguntaApiDocumentation {
static ApiCreate = applyDecorators( static ApiCreate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Crear una nueva pregunta', summary: 'Crear una nueva pregunta',
description: 'Crea una nueva pregunta para una sección', description: 'Crea una nueva pregunta para una sección'
}), }),
ApiBody({ ApiBody({
description: 'Datos de la pregunta a crear', description: 'Datos de la pregunta a crear',
@@ -29,27 +23,26 @@ export class PreguntaApiDocumentation {
tipo: { tipo: {
type: 'string', type: 'string',
enum: Object.values(TipoPreguntaEnum), enum: Object.values(TipoPreguntaEnum),
description: description: 'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)',
'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)', example: TipoPreguntaEnum.CERRADA
example: TipoPreguntaEnum.Cerrada,
}, },
contador_opcion: { type: 'number', example: 0 }, contador_opcion: { type: 'number', example: 0 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { validacion: {
type: 'string', type: 'string',
description: description: 'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)',
'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)', example: 'correo'
example: 'correo',
}, },
opciones: { opciones: {
type: 'array', type: 'array',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
valor: { type: 'string', example: 'Si' }, valor: { type: 'string', example: 'Si' }
}, }
}, }
}, }
}, }
}, },
examples: { examples: {
cerrada: { cerrada: {
@@ -57,35 +50,38 @@ export class PreguntaApiDocumentation {
value: { value: {
titulo: '¿Eres parte de la comunidad de la FES Acatlán?', titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
obligatoria: true, obligatoria: true,
tipo: TipoPreguntaEnum.Cerrada, tipo: TipoPreguntaEnum.CERRADA,
validacion: null, validacion: null,
opciones: [{ valor: 'Sí' }, { valor: 'No' }], opciones: [
}, { valor: 'Sí' },
{ valor: 'No' }
]
}
}, },
abierta: { abierta: {
summary: 'Pregunta de texto libre', summary: 'Pregunta de texto libre',
value: { value: {
titulo: '¿Qué mejorarías en nuestro servicio?', titulo: '¿Qué mejorarías en nuestro servicio?',
obligatoria: false, obligatoria: false,
tipo: TipoPreguntaEnum.AbiertaParrafo, tipo: TipoPreguntaEnum.ABIERTA,
validacion: null, validacion: null
}, }
}, },
multiple: { multiple: {
summary: 'Pregunta de selección múltiple', summary: 'Pregunta de selección múltiple',
value: { value: {
titulo: '¿Qué aspectos del servicio fueron de tu agrado?', titulo: '¿Qué aspectos del servicio fueron de tu agrado?',
obligatoria: false, obligatoria: false,
tipo: TipoPreguntaEnum.Multiple, tipo: TipoPreguntaEnum.MULTIPLE,
validacion: null, validacion: null,
opciones: [ opciones: [
{ valor: 'Rapidez' }, { valor: 'Rapidez' },
{ valor: 'Atención al cliente' }, { valor: 'Atención al cliente' },
{ valor: 'Facilidad de uso' }, { valor: 'Facilidad de uso' }
], ]
}, }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 201, status: 201,
@@ -98,19 +94,20 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true }, obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 }, contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 }, id_tipo_pregunta: { type: 'number', example: 1 },
validacion: { type: 'string', example: 'correo' }, id_opcion_dependiente: { type: 'number', example: null },
}, validacion: { type: 'string', example: 'correo' }
}, }
}
}), }),
ApiResponse({ status: 400, description: 'Datos de la pregunta inválidos' }), 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 // Documentación para obtener todas las preguntas
static ApiGetAll = applyDecorators( static ApiGetAll = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener todas las preguntas', summary: 'Obtener todas las preguntas',
description: 'Retorna una lista de todas las preguntas registradas', description: 'Retorna una lista de todas las preguntas registradas'
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -121,33 +118,31 @@ export class PreguntaApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_pregunta: { type: 'number', example: 1 }, id_pregunta: { type: 'number', example: 1 },
pregunta: { pregunta: { type: 'string', example: '¿Eres parte de la comunidad?' },
type: 'string',
example: '¿Eres parte de la comunidad?',
},
obligatoria: { type: 'boolean', example: true }, obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 }, contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 }, id_tipo_pregunta: { type: 'number', example: 1 },
validacion: { type: 'string', example: 'correo' }, 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 // Documentación para obtener una pregunta por ID
static ApiGetOne = applyDecorators( static ApiGetOne = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Obtener una pregunta por ID', 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({ ApiParam({
name: 'id', name: 'id',
description: 'ID de la pregunta', description: 'ID de la pregunta',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -160,6 +155,7 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true }, obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 }, contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 }, id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' }, validacion: { type: 'string', example: 'correo' },
opciones: { opciones: {
type: 'array', type: 'array',
@@ -167,29 +163,29 @@ export class PreguntaApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
id_opcion: { type: 'number', example: 1 }, 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: 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 // Documentación para actualizar una pregunta
static ApiUpdate = applyDecorators( static ApiUpdate = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Actualizar una pregunta', summary: 'Actualizar una pregunta',
description: 'Actualiza los datos de una pregunta existente', description: 'Actualiza los datos de una pregunta existente'
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID de la pregunta a actualizar', description: 'ID de la pregunta a actualizar',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiBody({ ApiBody({
description: 'Datos a actualizar de la pregunta', description: 'Datos a actualizar de la pregunta',
@@ -197,9 +193,9 @@ export class PreguntaApiDocumentation {
type: 'object', type: 'object',
properties: { properties: {
pregunta: { type: 'string', example: 'Pregunta actualizada' }, pregunta: { type: 'string', example: 'Pregunta actualizada' },
obligatoria: { type: 'boolean', example: false }, obligatoria: { type: 'boolean', example: false }
}, }
}, }
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -207,30 +203,27 @@ export class PreguntaApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { 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: 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 // Documentación para eliminar una pregunta
static ApiRemove = applyDecorators( static ApiRemove = applyDecorators(
ApiOperation({ ApiOperation({
summary: 'Eliminar una pregunta', summary: 'Eliminar una pregunta',
description: 'Elimina permanentemente una pregunta por su ID', description: 'Elimina permanentemente una pregunta por su ID'
}), }),
ApiParam({ ApiParam({
name: 'id', name: 'id',
description: 'ID de la pregunta a eliminar', description: 'ID de la pregunta a eliminar',
required: true, required: true,
type: 'number', type: 'number',
example: 1, example: 1
}), }),
ApiResponse({ ApiResponse({
status: 200, status: 200,
@@ -238,11 +231,11 @@ export class PreguntaApiDocumentation {
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
affected: { type: 'number', example: 1 }, affected: { type: 'number', example: 1 }
}, }
}, }
}), }),
ApiResponse({ status: 404, description: 'Pregunta no encontrada' }), 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
@@ -0,0 +1,18 @@
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
View File
@@ -42,6 +42,7 @@ export class PreguntaService {
pregunta.pregunta = createPreguntaDto.titulo; pregunta.pregunta = createPreguntaDto.titulo;
pregunta.obligatoria = createPreguntaDto.obligatoria || false; pregunta.obligatoria = createPreguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo; pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = createPreguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = createPreguntaDto.opciones?.length || 0; pregunta.contador_opcion = createPreguntaDto.opciones?.length || 0;
pregunta.validacion = createPreguntaDto.validacion || undefined; pregunta.validacion = createPreguntaDto.validacion || undefined;
@@ -157,6 +158,7 @@ export class PreguntaService {
// Actualizar los campos básicos de la pregunta // Actualizar los campos básicos de la pregunta
if (updatePreguntaDto.titulo) pregunta.pregunta = updatePreguntaDto.titulo; if (updatePreguntaDto.titulo) pregunta.pregunta = updatePreguntaDto.titulo;
if (updatePreguntaDto.obligatoria !== undefined) pregunta.obligatoria = updatePreguntaDto.obligatoria; 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; if (updatePreguntaDto.validacion !== undefined) pregunta.validacion = updatePreguntaDto.validacion;
// Si se proporciona un nuevo tipo de pregunta, actualizarlo // Si se proporciona un nuevo tipo de pregunta, actualizarlo
-12
View File
@@ -1,12 +0,0 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ValidateQrTokenDto {
@ApiProperty({
description: 'Token JWT del código QR',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
token: string;
}
-199
View File
@@ -1,199 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Asistencia } from 'src/asistencia/entities/asistencia.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Evento } from 'src/evento/entities/evento.entity';
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';
@Injectable()
export class QrTokenService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
@InjectRepository(Asistencia)
private readonly asistenciaRepository: Repository<Asistencia>,
@InjectRepository(Cuestionario)
private readonly cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(Evento)
private readonly eventoREpo: Repository<Evento>,
@InjectRepository(EventoUsuario)
private readonly eventoUsuarioRepo: Repository<EventoUsuario>,
@InjectRepository(RespuestaParticipanteAbierta)
private readonly respuestaAbiertaRepo: Repository<RespuestaParticipanteAbierta>,
) {}
/**
* Genera un token JWT específico para el QR de asistencia
* @param id_participante ID del participante
* @param id_evento ID del evento
* @param id_cuestionario ID del cuestionario
* @returns Token JWT codificado
*/
generateQrToken(
id_participante: number,
id_evento: number,
id_cuestionario: number,
fecha_fin: Date,
): string {
const payload = {
id_participante,
id_evento,
id_cuestionario,
type: 'qr_asistencia',
iat: Math.floor(Date.now() / 1000),
};
// Usar una clave secreta específica para QR o la misma que JWT general
const secret =
this.configService.get<string>('QR_JWT_SECRET') ||
this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta');
console.log('fecha de caducidad:', fecha_fin);
// Convert fecha_fin to seconds from now for expiresIn
const expiresInSeconds = Math.floor(
(fecha_fin.getTime() - Date.now()) / 1000,
);
console.log('expiresInSeconds:', expiresInSeconds);
console.log(
'Caducidad token: ',
Math.floor((fecha_fin.getTime() - Date.now()) / 1000),
);
return this.jwtService.sign(payload, {
secret,
expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 3600, // El QR puede ser válido por 30 días
});
}
/**
* Valida y decodifica un token de QR
* @param token Token JWT a validar
* @returns Datos decodificados del token o null si es inválido
*/
async validateQrToken(token: string): Promise<{
id_participante: number;
id_evento: number;
id_cuestionario: number;
type: string;
iat: number;
exp?: number;
contador?: number | null;
} | null> {
try {
const secret =
this.configService.get<string>('QR_JWT_SECRET') ||
this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta');
console.log(secret);
const decoded = this.jwtService.verify(token, { secret });
console.log('este es el token', decoded);
const evento = await this.eventoREpo.findOne({
where: { id_evento: decoded.id_evento },
});
if (!evento) {
throw new UnauthorizedException('Evento no encontrado');
}
const fechaFinEvento = new Date(evento.fecha_fin);
const now = new Date();
if (fechaFinEvento.getTime() < now.getTime()) {
throw new Error('El evento ha finalizado');
}
if (decoded.type !== 'qr_asistencia') {
return null;
}
const result: {
id_participante: number;
id_evento: number;
id_cuestionario: number;
type: string;
iat: number;
contador?: number | null;
exp: number;
} = {
id_participante: decoded.id_participante,
id_evento: decoded.id_evento,
id_cuestionario: decoded.id_cuestionario,
type: decoded.type,
iat: decoded.iat,
exp: decoded.exp,
};
console.log({ result });
const tieneListaRestringida =
(await this.eventoUsuarioRepo.count({
where: { id_evento: decoded.id_evento },
})) > 0;
if (tieneListaRestringida) {
const respuesta = await this.respuestaAbiertaRepo.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante: decoded.id_participante },
cuestionario: { id_cuestionario: decoded.id_cuestionario },
},
pregunta: {
validacion: TiposValidacion.CUENTA_ALUMNO,
},
},
relations: ['pregunta', 'cuestionarioRespondido'],
});
const respuestaRfc = respuesta
? null
: await this.respuestaAbiertaRepo.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante: decoded.id_participante },
cuestionario: { id_cuestionario: decoded.id_cuestionario },
},
pregunta: {
validacion: TiposValidacion.RFC,
},
},
relations: ['pregunta', 'cuestionarioRespondido'],
});
const identificadorRespuesta = respuesta ?? respuestaRfc;
if (identificadorRespuesta) {
const identificador = identificadorRespuesta.respuesta
.trim()
.toUpperCase();
const eventoUsuario = await this.eventoUsuarioRepo.findOne({
where: { id_evento: decoded.id_evento, identificador },
});
result.contador = eventoUsuario?.contador ?? null;
}
}
return result;
} catch (error) {
console.error('Error validando token QR:', error);
return null;
}
}
/**
* Verifica si un token QR es válido y no ha expirado
* @param token Token a verificar
* @returns true si es válido, false si no
*/
isTokenValid(token: string): boolean {
const decoded = this.validateQrToken(token);
return decoded !== null;
}
}

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