This commit is contained in:
miguel
2025-10-28 14:34:47 -06:00
129 changed files with 7057 additions and 2884 deletions
-4
View File
@@ -1,7 +1,5 @@
PORT=4200
db_type=mysql
db_host=mariadb # <-- uso del nombre del servicio, NO IP
db_port=3306
@@ -9,6 +7,4 @@ db_username=root # <-- te conectarás con root
db_password=mypass # <-- la misma que MARIADB_ROOT_PASSWORD
db_database=formularios_test
url_api_correos=http://host.docker.internal:4100 #http://localhost:4100
+3
View File
@@ -54,3 +54,6 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Uploaded files
/uploads
+3
View File
@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm install
# Instalar explícitamente las dependencias de autenticación
RUN npm install bcryptjs @types/bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt @types/passport-jwt
RUN npm i
COPY . .
+18 -113
View File
@@ -1,121 +1,26 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
El Sistema de Registros es una plataforma diseñada para gestionar eventos que requieren registro y control de asistencia mediante formularios personalizados.
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
Cada evento puede estar compuesto por varios subeventos (como conferencias, talleres o sesiones), y los participantes deben registrarse en aquellos que sean de su interés. Posteriormente, el sistema permite llevar un pase de lista para verificar la asistencia.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
📌 Tablas principales:
Evento: Representa un evento principal que requiere control de asistencia. Ejemplo: Data & AI Forum 2025.
## Description
Cuestionario: Corresponde a un subevento dentro de un evento. Por ejemplo, dentro del evento Data & AI Forum, puede haber varias conferencias y sesiones en paralelo, cada una representada como un Cuestionario.
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
Participante: Representa a una persona registrada en uno o varios subeventos (Cuestionario). Su asistencia puede ser verificada posteriormente.
## Project setup
```sql
Table Evento {
id_evento int [pk, increment]
tipo_evento varchar(50)
nombre_evento varchar(200)
descripcion_evento varchar(500)
fecha_inicio datetime [default: `CURRENT_TIMESTAMP`]
fecha_fin datetime [default: `CURRENT_TIMESTAMP`]
asistencias int
banner varchar
```bash
$ npm install
Note: 'Tabla que representa un evento general, como un foro o conferencia.'
}
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
# 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
volumes:
- mariadb_data:/var/lib/mysql
- ./init-scripts:/docker-entrypoint-initdb.d
- ./init-scripts:/docker-entrypoint-initdb.d # Base de datos simulada para escolares
restart: unless-stopped
api:
@@ -32,16 +32,16 @@ services:
front:
build: ../formularios_front
container_name: formularios_front
restart: always
depends_on:
- api
env_file:
- ../formularios_front/.env
ports:
- "4202:3000"
# front:
# build: ../formularios_front
# container_name: formularios_front
# restart: always
# depends_on:
# - api
# env_file:
# - ../formularios_front/.env
# ports:
# - "4202:3000"
volumes:
+24 -35
View File
@@ -1,35 +1,24 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);
module.exports = {
// parser: '@typescript-eslint/parser',
// parserOptions: {
// project: 'tsconfig.json',
// sourceType: 'module',
// },
// plugins: ['@typescript-eslint/eslint-plugin'],
// extends: [
// 'plugin:@typescript-eslint/recommended',
// 'plugin:prettier/recommended',
// ],
// root: true,
// env: {
// node: true,
// jest: true,
// },
// ignorePatterns: ['.eslintrc.js'],
// rules: {
// '@typescript-eslint/interface-name-prefix': 'off',
// '@typescript-eslint/explicit-function-return-type': 'off',
// '@typescript-eslint/explicit-module-boundary-types': 'off',
// '@typescript-eslint/no-explicit-any': 'off',
// },
};
+14
View File
@@ -0,0 +1,14 @@
INSERT INTO tipo_evento (tipo_evento) VALUES
('Académico'),
('Taller / Capacitación'),
('Conferencia / Charla'),
('Panel / Mesa redonda'),
('Feria / Expo'),
('Networking / Vinculación'),
('Entrevista / Sesión 1:1'),
('Cultural / Artístico'),
('Deportivo / Recreativo'),
('Institucional / Ceremonial'),
('Social / Comunitario'),
('Aniversario'),
('Otro');
+1253 -184
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -27,7 +27,8 @@
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.0",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
@@ -37,13 +38,15 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"mysql2": "^3.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.21"
"typeorm": "^0.3.21",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -55,6 +58,7 @@
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.13",
"@types/node": "^22.10.7",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
+10
View File
@@ -0,0 +1,10 @@
{
"Nombre": "Sistema de Registro Eventos",
"ip": "192.168.0.10",
"email": "registro-eventos@acatlan.unam.mx",
"email_password": "c4Gl-06k9%h",
"password": "E5cCF5b-92d",
"clientId": "963087784448-i4pg060bkumkhk75r4po5esaglij6dq2.apps.googleusercontent.com",
"refreshToken": "1//04-ba9LCW4EQ9CgYIARAAGAQSNwF-L9IrOlLXH2Piku5W37ujhF4N3sBMMJqTAvXhjzKiYy7ckuOoA6sIcBZpu-d-E7-no286kzU",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaXN0ZW1hSWQiOjIsIm5vbWJyZSI6IlNpc3RlbWEgZGUgUmVnaXN0cm8gRXZlbnRvcyIsImlwIjoiMTkyLjE2OC4wLjEwIiwiaWF0IjoxNzUxMTU1OTU0LCJleHAiOjE3NTEyNDIzNTR9.9JzBnjlkw9SqTAlpN2UfsWwLS7HzeIyosfRCNA3Lj0Y%"
}
+83 -54
View File
@@ -1,72 +1,101 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AdministradorService } from './administrador.service';
import { Administrador } from './administrador.entity';
import { Administrador } from './entities/administrador.entity';
import { CreateAdministradorDto } from './dto/create-administrador.dto';
import { UpdateAdministradorDto } from './dto/update.administrador.dto';
import { LoginAdministradorDto } from './dto/login-administrador.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdministradorApiDocumentation } from './administrador.documentation';
import { AdministradorApiDocumentation } from './docs/administrador.documentation';
import { JwtValidationGuard } from 'src/auth/guards/jwt-validation.guard';
import { Roles } from 'src/auth/decorators/roles.decorator';
import { RolesGuard } from 'src/auth/guards/roles.guard';
@Controller('administrador')
@ApiBearerAuth()
@AdministradorApiDocumentation.ApiController
@ApiBearerAuth()
@Controller('administrador')
export class AdministradorController {
constructor(private administradorService: AdministradorService) {}
constructor(private administradorService: AdministradorService) {}
@Get()
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiGetAll
getAdministradores(): Promise<Administrador[]> {
return this.administradorService.getAdministradores();
}
@Get()
@UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiGetAll
getAdministradores(): Promise<Administrador[]> {
return this.administradorService.getAdministradores();
}
@Get(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiGetOne
getAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.getAdminByIdOrFail(id);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiGetOne
getAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.getAdministrador(id);
}
@Post()
@Roles('administrador')
@AdministradorApiDocumentation.ApiCreate
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
return this.administradorService.createAdministrador(newAdministrador);
}
@Post()
@AdministradorApiDocumentation.ApiCreate
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
return this.administradorService.createAdministrador(newAdministrador);
}
@Post('login')
@AdministradorApiDocumentation.ApiLogin
login(@Body() loginData: LoginAdministradorDto) {
return this.administradorService.login(
loginData.nombre_usuario,
loginData.password,
);
}
@Post('login')
@AdministradorApiDocumentation.ApiLogin
login(@Body() loginData: LoginAdministradorDto) {
return this.administradorService.login(loginData.correo, loginData.password);
}
@Patch(':id/change-password')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiChangePassword
changePassword(
@Param('id', ParseIntPipe) id: number,
@Body() changePasswordDto: ChangePasswordDto,
) {
return this.administradorService.changePassword(
id,
changePasswordDto.currentPassword,
changePasswordDto.newPassword,
);
}
@Patch(':id/change-password')
@UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiChangePassword
changePassword(
@Param('id', ParseIntPipe) id: number,
@Body() changePasswordDto: ChangePasswordDto
) {
return this.administradorService.changePassword(
id,
changePasswordDto.currentPassword,
changePasswordDto.newPassword
);
}
@Delete(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiRemove
deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.deleteAdministrador(id);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiRemove
deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
return this.administradorService.deleteAdministrador(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
@AdministradorApiDocumentation.ApiUpdate
updateAdministrador(@Param('id', ParseIntPipe) id: number, @Body() administrador: UpdateAdministradorDto) {
return this.administradorService.updateAdministrador(id, administrador);
}
@Patch(':id')
@UseGuards(RolesGuard)
@UseGuards(JwtValidationGuard)
@Roles('administrador')
@AdministradorApiDocumentation.ApiUpdate
updateAdministrador(
@Param('id', ParseIntPipe) id: number,
@Body() administrador: UpdateAdministradorDto,
) {
return this.administradorService.updateAdministrador(id, administrador);
}
}
-34
View File
@@ -1,34 +0,0 @@
import { TipoUser } from "src/tipo_user/tipo_user.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Administrador {
@PrimaryGeneratedColumn()
id_admnistrador: number
@Column({ unique: true })
correo: string
@Column()
password: string
/*
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administradores)
@JoinColumn({ name: "id_tipo_user" })
tipoUser: TipoUser;
*/
//Relacion con tipo_user
@Column()
id_tipo_user: number
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador)
tipoUser: TipoUser[]
/*
@OneToMany(() => Evento, (evento) => evento.administrador)
eventos: Evento[];
@OneToMany(() => Asistencia, (asistencia) => asistencia.administrador)
asistencias: Asistencia[];
*/
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AdministradorService } from './administrador.service';
import { AdministradorController } from './administrador.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Administrador } from './administrador.entity';
import { Administrador } from './entities/administrador.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
+181 -134
View File
@@ -1,6 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Administrador } from './administrador.entity';
import { Administrador } from './entities/administrador.entity';
import { Repository } from 'typeorm';
import { CreateAdministradorDto } from './dto/create-administrador.dto';
import { UpdateAdministradorDto } from './dto/update.administrador.dto';
@@ -9,163 +9,210 @@ import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AdministradorService {
constructor(
@InjectRepository(Administrador) private administradorRepository: Repository<Administrador>,
private jwtService: JwtService
) {}
constructor(
@InjectRepository(Administrador)
private administradorRepository: Repository<Administrador>,
private jwtService: JwtService,
) {}
async createAdministrador(administrador: CreateAdministradorDto) {
// Verificar si ya existe un administrador con el mismo correo
const administradorFound = await this.administradorRepository.findOne({
where: {
correo: administrador.correo
}
});
async createAdministrador(administrador: CreateAdministradorDto) {
// Verificar si ya existe un administrador con el mismo correo
const administradorFound = await this.administradorRepository.findOne({
where: {
correo: administrador.correo,
},
});
if (administradorFound) {
throw new HttpException('El correo ya está registrado', HttpStatus.CONFLICT);
}
// Encriptar la contraseña
const hashedPassword = await this.hashPassword(administrador.password);
// Crear y guardar el nuevo administrador
const newAdministrador = this.administradorRepository.create({
correo: administrador.correo,
password: hashedPassword,
id_tipo_user: administrador.id_tipo_user
});
const savedAdministrador = await this.administradorRepository.save(newAdministrador);
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
if (administradorFound) {
throw new HttpException(
'El correo ya está registrado',
HttpStatus.CONFLICT,
);
}
async login(correo: string, password: string) {
// Buscar administrador por correo
const administrador = await this.administradorRepository.findOne({
where: { correo },
relations: ['tipoUser']
});
// Encriptar la contraseña
const hashedPassword = await this.hashPassword(administrador.password);
if (!administrador) {
throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
}
// Crear y guardar el nuevo administrador
const newAdministrador = this.administradorRepository.create({
nombre_usuario: administrador.nombre_usuario,
correo: administrador.correo,
password: hashedPassword,
tipoUser: { id_tipo_user: administrador.id_tipo_user }, // Asignar el tipo de usuario
});
// Verificar contraseña
const isPasswordValid = await bcrypt.compare(password, administrador.password);
if (!isPasswordValid) {
throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
}
const savedAdministrador =
await this.administradorRepository.save(newAdministrador);
// Generar token JWT
const payload = {
sub: administrador.id_admnistrador,
correo: administrador.correo,
id_tipo_user: administrador.id_tipo_user
};
return {
access_token: this.jwtService.sign(payload),
id_administrador: administrador.id_admnistrador,
correo: administrador.correo,
tipo_user: administrador.id_tipo_user
};
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
}
async login(nombre_usuario: string, password: string) {
// Buscar administrador por nombre de usuario
const administrador = await this.administradorRepository.findOne({
where: { nombre_usuario },
relations: ['tipoUser'],
});
if (!administrador) {
throw new HttpException(
'Credenciales inválidas',
HttpStatus.UNAUTHORIZED,
);
}
async changePassword(id_admnistrador: number, currentPassword: string, newPassword: string) {
// Buscar administrador
const administrador = await this.administradorRepository.findOne({
where: { id_admnistrador }
});
if (!administrador) {
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
}
// Verificar contraseña actual
const isPasswordValid = await bcrypt.compare(currentPassword, administrador.password);
if (!isPasswordValid) {
throw new HttpException('Contraseña actual incorrecta', HttpStatus.BAD_REQUEST);
}
// Hashear y actualizar nueva contraseña
administrador.password = await this.hashPassword(newPassword);
await this.administradorRepository.save(administrador);
return { message: 'Contraseña actualizada correctamente' };
// Verificar contraseña
const isPasswordValid = await bcrypt.compare(
password,
administrador.password,
);
if (!isPasswordValid) {
throw new HttpException(
'Credenciales inválidas',
HttpStatus.UNAUTHORIZED,
);
}
private async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt();
return bcrypt.hash(password, salt);
// Generar token JWT
const payload = {
sub: administrador.id_administrador,
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,
);
}
getAdministradores() {
return this.administradorRepository.find({
relations: ['tipoUser'],
select: {
id_admnistrador: true,
correo: true,
id_tipo_user: true
}
});
// Verificar contraseña actual
const isPasswordValid = await bcrypt.compare(
currentPassword,
administrador.password,
);
if (!isPasswordValid) {
throw new HttpException(
'Contraseña actual incorrecta',
HttpStatus.BAD_REQUEST,
);
}
async getAdministrador(id_admnistrador: number) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_admnistrador
},
relations: ['tipoUser'],
select: {
id_admnistrador: true,
correo: true,
id_tipo_user: true
}
});
// Hashear y actualizar nueva contraseña
administrador.password = await this.hashPassword(newPassword);
await this.administradorRepository.save(administrador);
if (!administradorFound) {
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
}
return { message: 'Contraseña actualizada correctamente' };
}
return administradorFound;
private async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt();
return bcrypt.hash(password, salt);
}
getAdministradores() {
return this.administradorRepository.find({
relations: ['tipoUser'],
select: {
nombre_usuario: true,
id_administrador: true,
correo: true,
},
});
}
async getAdminByIdOrFail(id_administrador: number) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
});
if (!administradorFound) {
throw new HttpException(
'Administrador no encontrado',
HttpStatus.NOT_FOUND,
);
}
async deleteAdministrador(id_admnistrador: number) {
const result = await this.administradorRepository.delete({ id_admnistrador });
return administradorFound;
}
if (result.affected === 0) {
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
}
async getAdminById(id_administrador: number) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
relations: ['tipoUser'],
});
return result;
return administradorFound;
}
async deleteAdministrador(id_administrador: number) {
const result = await this.administradorRepository.delete({
id_administrador,
});
if (result.affected === 0) {
throw new HttpException(
'Administrador no encontrado',
HttpStatus.NOT_FOUND,
);
}
async updateAdministrador(id_admnistrador: number, administrador: UpdateAdministradorDto) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_admnistrador
}
});
return result;
}
if (!administradorFound) {
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
}
async updateAdministrador(
id_administrador: number,
administrador: UpdateAdministradorDto,
) {
const administradorFound = await this.administradorRepository.findOne({
where: {
id_administrador,
},
});
// Si se incluye una nueva contraseña, hashearla
if (administrador.password) {
administrador.password = await this.hashPassword(administrador.password);
}
const updateAdministrador = Object.assign(administradorFound, administrador);
const savedAdministrador = await this.administradorRepository.save(updateAdministrador);
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
if (!administradorFound) {
throw new HttpException(
'Administrador no encontrado',
HttpStatus.NOT_FOUND,
);
}
// Si se incluye una nueva contraseña, hashearla
if (administrador.password) {
administrador.password = await this.hashPassword(administrador.password);
}
const updateAdministrador = Object.assign(
administradorFound,
administrador,
);
const savedAdministrador =
await this.administradorRepository.save(updateAdministrador);
// Excluir la contraseña de la respuesta
const { password, ...result } = savedAdministrador;
return result;
}
}
@@ -1,15 +1,22 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
export class AdministradorApiDocumentation {
// Decorador para toda la clase del controlador
static ApiController = ApiTags('Administradores');
static ApiController = applyDecorators(ApiTags('Administrador'));
// Documentación para crear un administrador
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Registrar un nuevo administrador',
description: 'Crea un nuevo administrador con correo, contraseña y tipo de administrador'
description:
'Crea un nuevo administrador con correo, contraseña y tipo de administrador',
}),
ApiBody({
description: 'Datos del administrador a registrar',
@@ -17,47 +24,56 @@ export class AdministradorApiDocumentation {
type: 'object',
required: ['correo', 'password', 'id_tipo_user'],
properties: {
correo: {
type: 'string',
nombre_usuario: {
type: 'string',
example: 'mike',
description: 'Nombre de usuario del administrador',
},
correo: {
type: 'string',
format: 'email',
example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador'
description: 'Correo electrónico del administrador',
},
password: {
type: 'string',
password: {
type: 'string',
format: 'password',
example: 'Password123',
description: 'Contraseña del administrador (mínimo 6 caracteres)'
example: 'password',
description: 'Contraseña del administrador (mínimo 6 caracteres)',
},
id_tipo_user: {
type: 'integer',
id_tipo_user: {
type: 'integer',
example: 1,
description: 'ID del tipo de usuario administrador'
}
}
}
description: 'ID del tipo de usuario administrador',
},
},
},
}),
ApiResponse({
status: 201,
ApiResponse({
status: 201,
description: 'Administrador registrado exitosamente',
schema: {
type: 'object',
properties: {
id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 }
}
}
id_tipo_user: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 400, description: 'Datos del administrador inválidos' }),
ApiResponse({ status: 409, description: 'El correo ya está registrado' })
ApiResponse({
status: 400,
description: 'Datos del administrador inválidos',
}),
ApiResponse({ status: 409, description: 'El correo ya está registrado' }),
);
// Documentación para iniciar sesión
static ApiLogin = applyDecorators(
ApiOperation({
summary: 'Iniciar sesión como administrador',
description: 'Autentica un administrador con correo y contraseña, y devuelve un token JWT'
description:
'Autentica un administrador con correo y contraseña, y devuelve un token JWT',
}),
ApiBody({
description: 'Credenciales de inicio de sesión',
@@ -65,58 +81,59 @@ export class AdministradorApiDocumentation {
type: 'object',
required: ['correo', 'password'],
properties: {
correo: {
type: 'string',
correo: {
type: 'string',
format: 'email',
example: 'admin@ejemplo.com',
description: 'Correo electrónico del administrador'
description: 'Correo electrónico del administrador',
},
password: {
type: 'string',
password: {
type: 'string',
format: 'password',
example: 'Password123',
description: 'Contraseña del administrador'
}
}
}
example: 'password',
description: 'Contraseña del administrador',
},
},
},
}),
ApiResponse({
status: 200,
ApiResponse({
status: 200,
description: 'Inicio de sesión exitoso',
schema: {
type: 'object',
properties: {
access_token: {
type: 'string',
access_token: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Token JWT para autenticación'
description: 'Token JWT para autenticación',
},
id_administrador: {
type: 'number',
id_administrador: {
type: 'number',
example: 1,
description: 'ID del administrador autenticado'
description: 'ID del administrador autenticado',
},
correo: {
type: 'string',
correo: {
type: 'string',
example: 'admin@ejemplo.com',
description: 'Correo del administrador autenticado'
description: 'Correo del administrador autenticado',
},
tipo_user: {
type: 'number',
tipo_user: {
type: 'number',
example: 1,
description: 'Tipo de usuario del administrador'
}
}
}
description: 'Tipo de usuario del administrador',
},
},
},
}),
ApiResponse({ status: 401, description: 'Credenciales inválidas' })
ApiResponse({ status: 401, description: 'Credenciales inválidas' }),
);
// Documentación para obtener todos los administradores
static ApiGetAll = applyDecorators(
ApiOperation({
summary: 'Obtener todos los administradores',
description: 'Retorna una lista de todos los administradores registrados (requiere autenticación)'
description:
'Retorna una lista de todos los administradores registrados (requiere autenticación)',
}),
ApiResponse({
status: 200,
@@ -135,28 +152,32 @@ export class AdministradorApiDocumentation {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Administrador General' }
}
}
}
}
}
}
tipo_user: {
type: 'string',
example: 'Administrador General',
},
},
},
},
},
},
},
}),
ApiResponse({ status: 401, description: 'No autorizado' })
ApiResponse({ status: 401, description: 'No autorizado' }),
);
// Documentación para obtener un administrador por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener un administrador por ID',
description: 'Retorna los datos de un administrador específico según su ID (requiere autenticación)'
description:
'Retorna los datos de un administrador específico según su ID (requiere autenticación)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador',
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -173,53 +194,55 @@ export class AdministradorApiDocumentation {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Administrador General' }
}
}
}
}
}
tipo_user: { type: 'string', example: 'Administrador General' },
},
},
},
},
},
}),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' })
ApiResponse({ status: 401, description: 'No autorizado' }),
);
// Documentación para actualizar un administrador
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar datos de un administrador',
description: 'Actualiza la información de un administrador existente (requiere autenticación)'
description:
'Actualiza la información de un administrador existente (requiere autenticación)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador a actualizar',
type: 'number',
example: 1
example: 1,
}),
ApiBody({
description: 'Datos a actualizar del administrador',
schema: {
type: 'object',
properties: {
correo: {
type: 'string',
correo: {
type: 'string',
format: 'email',
example: 'nuevo_admin@ejemplo.com',
description: 'Nuevo correo electrónico del administrador'
description: 'Nuevo correo electrónico del administrador',
},
password: {
type: 'string',
password: {
type: 'string',
format: 'password',
example: 'NuevaPassword123',
description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
description:
'Nueva contraseña del administrador (mínimo 6 caracteres)',
},
id_tipo_user: {
type: 'integer',
id_tipo_user: {
type: 'integer',
example: 2,
description: 'Nuevo tipo de usuario administrador'
}
}
}
description: 'Nuevo tipo de usuario administrador',
},
},
},
}),
ApiResponse({
status: 200,
@@ -229,26 +252,30 @@ export class AdministradorApiDocumentation {
properties: {
id_admnistrador: { type: 'number', example: 1 },
correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' },
id_tipo_user: { type: 'number', example: 2 }
}
}
id_tipo_user: { type: 'number', example: 2 },
},
},
}),
ApiResponse({
status: 400,
description: 'Datos de actualización inválidos',
}),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' })
ApiResponse({ status: 401, description: 'No autorizado' }),
);
// Documentación para cambiar contraseña
static ApiChangePassword = applyDecorators(
ApiOperation({
summary: 'Cambiar contraseña de un administrador',
description: 'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)'
description:
'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador',
type: 'number',
example: 1
example: 1,
}),
ApiBody({
description: 'Datos para cambio de contraseña',
@@ -256,20 +283,21 @@ export class AdministradorApiDocumentation {
type: 'object',
required: ['currentPassword', 'newPassword'],
properties: {
currentPassword: {
type: 'string',
currentPassword: {
type: 'string',
format: 'password',
example: 'Password123',
description: 'Contraseña actual del administrador'
description: 'Contraseña actual del administrador',
},
newPassword: {
type: 'string',
newPassword: {
type: 'string',
format: 'password',
example: 'NuevaPassword123',
description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
}
}
}
description:
'Nueva contraseña del administrador (mínimo 6 caracteres)',
},
},
},
}),
ApiResponse({
status: 200,
@@ -277,26 +305,30 @@ export class AdministradorApiDocumentation {
schema: {
type: 'object',
properties: {
message: { type: 'string', example: 'Contraseña actualizada correctamente' }
}
}
message: {
type: 'string',
example: 'Contraseña actualizada correctamente',
},
},
},
}),
ApiResponse({ status: 400, description: 'Contraseña actual incorrecta' }),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' })
ApiResponse({ status: 401, description: 'No autorizado' }),
);
// Documentación para eliminar un administrador
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar un administrador',
description: 'Elimina permanentemente un administrador por su ID (requiere autenticación)'
description:
'Elimina permanentemente un administrador por su ID (requiere autenticación)',
}),
ApiParam({
name: 'id',
description: 'ID del administrador a eliminar',
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -304,11 +336,11 @@ export class AdministradorApiDocumentation {
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
ApiResponse({ status: 401, description: 'No autorizado' })
ApiResponse({ status: 401, description: 'No autorizado' }),
);
}
}
@@ -2,18 +2,31 @@ import { IsEmail, IsNotEmpty, IsNumber, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateAdministradorDto {
@ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' })
@IsEmail({}, { message: 'El correo no es válido' })
@IsNotEmpty({ message: 'El correo es requerido' })
correo: string;
@ApiProperty({
example: 'admin@example.com',
description: 'Correo del administrador',
})
@IsEmail({}, { message: 'El correo no es válido' })
@IsNotEmpty({ message: 'El correo es requerido' })
correo: string;
@ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' })
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
@IsNotEmpty({ message: 'La contraseña es requerida' })
password: string;
@ApiProperty({
example: 'mike',
description: 'Nombre de usuario del administrador',
})
@IsNotEmpty({ message: 'El nombre de usuario es requerido' })
nombre_usuario: string;
@ApiProperty({ example: 1, description: 'ID del tipo de usuario' })
@IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
@IsNotEmpty({ message: 'El tipo de usuario es requerido' })
id_tipo_user: number;
}
@ApiProperty({
example: 'Password123',
description: 'Contraseña del administrador',
})
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
@IsNotEmpty({ message: 'La contraseña es requerida' })
password: string;
@ApiProperty({ example: 1, description: 'ID del tipo de usuario' })
@IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
@IsNotEmpty({ message: 'El tipo de usuario es requerido' })
id_tipo_user: number;
}
@@ -1,13 +1,18 @@
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginAdministradorDto {
@ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' })
@IsEmail({}, { message: 'El correo no es válido' })
@IsNotEmpty({ message: 'El correo es requerido' })
correo: string;
@ApiProperty({
example: 'mike',
description: 'Nombre de usuario del administrador',
})
@IsNotEmpty({ message: 'El nombre de usuario es requerido' })
nombre_usuario: string;
@ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' })
@IsNotEmpty({ message: 'La contraseña es requerida' })
password: string;
}
@ApiProperty({
example: 'Password123',
description: 'Contraseña del administrador',
})
@IsNotEmpty({ message: 'La contraseña es requerida' })
password: string;
}
@@ -0,0 +1,30 @@
import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('administrador')
export class Administrador {
@PrimaryGeneratedColumn()
id_administrador: number;
@Column({ unique: true })
nombre_usuario: string;
@Column({ unique: true })
correo: string;
@Column()
password: string;
@Column({ type: 'int' })
id_tipo_user: number;
@ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador)
@JoinColumn({ name: 'id_tipo_user' })
tipoUser: TipoUser;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { AlumnosService } from './alumnos.service';
import { AlumnosService, UsuarioData } from './alumnos.service';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
@@ -14,7 +14,7 @@ export class AlumnosController {
@ApiResponse(ALUMNO_RESPONSES[200])
@ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<AlumnoDto | null> {
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<UsuarioData | null> {
const alumno = await this.alumnosService.findByCuenta(cuenta);
if (!alumno) {
throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`);
+1 -41
View File
@@ -3,49 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AlumnosController } from './alumnos.controller';
import { AlumnosService } from './alumnos.service';
import { RegistroAlumno } from './entities/registro-alumno.entity';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
TypeOrmModule.forRootAsync({
name: 'alumnosConnection',
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get<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) │ │
}), */
],
imports: [TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection')],
controllers: [AlumnosController],
providers: [AlumnosService],
exports: [AlumnosService],
+19 -2
View File
@@ -3,6 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity';
export type UsuarioData = {
cuenta: string | null;
nombre: string | null;
apellidos: string | null;
carrera: string | null;
genero: string | null;
rfc?: string | null; // Solo para trabajadores
}
@Injectable()
export class AlumnosService {
constructor(
@@ -10,9 +19,17 @@ export class AlumnosService {
private registroAlumnoRepository: Repository<RegistroAlumno>,
) {}
async findByCuenta(cuenta: string): Promise<RegistroAlumno | null> {
return this.registroAlumnoRepository.findOne({
async findByCuenta(cuenta: string): Promise<UsuarioData | null> {
const alumno = await this.registroAlumnoRepository.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
return {
cuenta: alumno?.id_ncuenta.toString() || null,
nombre: alumno?.nombre || null,
apellidos: alumno?.apellidos || null,
carrera: alumno?.carrera || null,
genero: alumno?.genero || null,
}
}
}
+39 -58
View File
@@ -2,6 +2,11 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createAlumnosDbConfig, createMainDbConfig } from './db/typeorm.config';
import { AdministradorModule } from './administrador/administrador.module';
import { AuthModule } from './auth/auth.module';
import { CuestionarioModule } from './cuestionario/cuestionario.module';
import { TipoCuestionarioModule } from './tipo_cuestionario/tipo_cuestionario.module';
import { CuestionarioSeccionModule } from './cuestionario_seccion/cuestionario_seccion.module';
@@ -14,80 +19,56 @@ import { PreguntaOpcionModule } from './pregunta_opcion/pregunta_opcion.module';
import { RespuestaParticipanteCerradaModule } from './respuesta_participante_cerrada/respuesta_participante_cerrada.module';
import { CuestionarioRespondidoModule } from './cuestionario_respondido/cuestionario_respondido.module';
import { RespuestaParticipanteAbiertaModule } from './respuesta_participante_abierta/respuesta_participante_abierta.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { EventoModule } from './evento/evento.module';
import { TipoUserModule } from './tipo_user/tipo_user.module';
import { ParticipanteModule } from './participante/participante.module';
import { QrModule } from './qr/qr.module';
import { AdministradorModule } from './administrador/administrador.module';
import { AsistenciaModule } from './asistencia/asistencia.module';
import { ParticipanteEventoModule } from './participante_evento/participante_evento.module';
import { ValidacionesModule } from './validaciones/validaciones.module';
import { AlumnosModule } from './alumnos/alumnos.module';
import { AuthModule } from './auth/auth.module';
import { TrabajadoresModule } from './trabajadores/trabajadores.module';
import { TipoEventoModule } from './tipo_evento/tipo_evento.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: createMainDbConfig,
inject: [ConfigService],
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.db_host, //process.env.db_host
username: process.env.db_username,
database: process.env.db_database,
password: process.env.db_password,
port: Number(process.env.db_port),
synchronize: false,
dropSchema: false, // elimina la base de datos
// logging: true, // Habilita los logs para depuración
autoLoadEntities: true, // Carga automáticamente las entidades
//esta es mi base de datos local
/*
type: 'mysql',
host: 'localhost',
port: 3306, //3306
username: 'root',
password: 'admin', //admin
database: 'cidwa', //nestdb
*/
// entities: [__dirname + '/**/*.entity{.ts,.js}'],
// synchronize: true,
//extra
retryAttempts: 10, // Intentos para reconectar
retryDelay: 3000, // Tiempo entre reintentos
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
name: 'alumnosConnection',
useFactory: createAlumnosDbConfig,
inject: [ConfigService],
}),
AuthModule,
CuestionarioModule,
TipoCuestionarioModule,
CuestionarioSeccionModule,
SeccionModule,
SeccionPreguntaModule,
PreguntaModule,
TipoPreguntaModule,
OpcionModule, PreguntaOpcionModule,
RespuestaParticipanteCerradaModule,
CuestionarioRespondidoModule,
RespuestaParticipanteAbiertaModule,
EventoModule,
TipoUserModule,
ParticipanteModule,
QrModule,
AdministradorModule,
AsistenciaModule,
ParticipanteEventoModule,
ValidacionesModule,
AlumnosModule,
AsistenciaModule,
AuthModule,
CuestionarioModule,
CuestionarioRespondidoModule,
CuestionarioSeccionModule,
EventoModule,
OpcionModule,
ParticipanteEventoModule,
ParticipanteModule,
PreguntaModule,
PreguntaOpcionModule,
RespuestaParticipanteAbiertaModule,
RespuestaParticipanteCerradaModule,
SeccionModule,
SeccionPreguntaModule,
TipoCuestionarioModule,
TipoEventoModule,
TipoPreguntaModule,
TipoUserModule,
ValidacionesModule,
QrModule,
TrabajadoresModule,
],
controllers: [AppController],
+1 -1
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { AsistenciaService } from './asistencia.service';
import { Asistencia } from './asistencia.entity';
import { Asistencia } from './entities/asistencia.entity';
import { CreateAsistenciaDto } from './dto/create-asistencia.dto';
import { UpdateAsistenciaDto } from './dto/update.asistencia.dto';
-41
View File
@@ -1,41 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Asistencia {
@PrimaryGeneratedColumn()
id_asistecia: number
//({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }
@Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP'})
fecha_asistencia: Date
@Column()
metodo: boolean
@Column()
estado: boolean
//relaciones con las otras tablas
@Column()
id_participante: number
@Column()
id_evento: number
@Column()
id_administrador: number
/*
@ManyToOne(() => Administrador, (admin) => admin.asistencias)
@JoinColumn({ name: "id_administrador" })
administrador: Administrador;
@ManyToOne(() => Evento, (evento) => evento.asistencias)
@JoinColumn({ name: "id_evento" })
evento: Evento;
@ManyToOne(() => Participante, (participante) => participante.asistencias)
@JoinColumn({ name: "id_participante" })
participante: Participante;
*/
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AsistenciaService } from './asistencia.service';
import { AsistenciaController } from './asistencia.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Asistencia } from './asistencia.entity';
import { Asistencia } from './entities/asistencia.entity';
@Module({
imports: [TypeOrmModule.forFeature([Asistencia])],
+1 -1
View File
@@ -1,6 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Asistencia } from './asistencia.entity';
import { Asistencia } from './entities/asistencia.entity';
import { Repository } from 'typeorm';
import { CreateAsistenciaDto } from './dto/create-asistencia.dto';
import { UpdateAsistenciaDto } from './dto/update.asistencia.dto';
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('asistencia')
export class Asistencia {
@PrimaryGeneratedColumn()
id_asistecia: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_asistencia: Date;
@Column()
metodo: boolean;
@Column()
estado: boolean;
@Column()
id_participante: number;
@Column()
id_evento: number;
@Column()
id_administrador: number;
}
+12 -6
View File
@@ -2,17 +2,23 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Administrador } from '../administrador/administrador.entity';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Administrador } from '../administrador/entities/administrador.entity';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET || 'tu_clave_secreta',
signOptions: {
expiresIn: '24h', // Tokens expiran en 24 horas
},
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET', 'tu_clave_secreta'),
signOptions: {
expiresIn: '24h',
},
}),
}),
TypeOrmModule.forFeature([Administrador]),
],
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ('administrador' | 'staff')[]) =>
SetMetadata(ROLES_KEY, roles);
+52
View File
@@ -0,0 +1,52 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdministradorService } from 'src/administrador/administrador.service';
@Injectable()
export class JwtValidationGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private administradorService: AdministradorService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('Token no proporcionado');
}
const token = authHeader.split(' ')[1];
try {
const decodedToken = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET,
});
const administrador = await this.administradorService.getAdminById(
decodedToken.sub,
);
if (!administrador) {
throw new UnauthorizedException('Usuario no encontrado');
}
request.user = administrador;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new UnauthorizedException(
`La sesión ha expirado, inicia sesión nuevamente.`,
);
}
throw new UnauthorizedException('Token inválido');
}
return true;
}
}
+46
View File
@@ -0,0 +1,46 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>(
ROLES_KEY,
context.getHandler(),
);
if (!requiredRoles) return true; // Si no hay roles definidos, permitir acceso.
const request = context.switchToHttp().getRequest();
const user = request.user; // Usuario ya validado en JwtValidationGuard
if (!user) {
throw new ForbiddenException('Usuario no autenticado');
}
// Obtener el tipo de usuario
const tipo_usuario = user.tipoUser.tipo;
if (!tipo_usuario) {
throw new ForbiddenException('No se pudo determinar el tipo de usuario');
}
// Validar si el usuario tiene uno de los roles requeridos
const hasRole = requiredRoles.includes(tipo_usuario);
if (!hasRole) {
throw new ForbiddenException(
'No tienes permisos para acceder a este recurso',
);
}
return true;
}
}
+7 -8
View File
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Administrador } from '../administrador/administrador.entity';
import { Administrador } from '../administrador/entities/administrador.entity';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -19,15 +19,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: any) {
const { sub: id_admnistrador } = payload;
const { sub: id_administrador } = payload;
const administrador = await this.administradorRepository.findOne({
where: { id_admnistrador },
where: { id_administrador },
select: {
id_admnistrador: true,
id_administrador: true,
correo: true,
id_tipo_user: true
}
},
});
if (!administrador) {
@@ -36,4 +35,4 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return administrador;
}
}
}
@@ -1,20 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CuestionarioController } from './cuestionario.controller';
import { CuestionarioService } from './cuestionario.service';
describe('CuestionarioController', () => {
let controller: CuestionarioController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CuestionarioController],
providers: [CuestionarioService],
}).compile();
controller = module.get<CuestionarioController>(CuestionarioController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+169 -5
View File
@@ -1,9 +1,31 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
ParseIntPipe,
UploadedFile,
UnprocessableEntityException,
UploadedFiles,
BadRequestException,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { CuestionarioService } from './cuestionario.service';
import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { CuestionarioApiDocumentation } from './cuestionario.documentation';
import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation';
import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto';
import * as fs from 'fs';
import { NotFoundError } from 'rxjs';
import { error } from 'console';
@Controller('cuestionario')
@CuestionarioApiDocumentation.ApiController
@@ -16,18 +38,157 @@ export class CuestionarioController {
return this.cuestionarioService.create(createCuestionarioDto);
}
@Post('banners/bulk')
@UseInterceptors(
FilesInterceptor('banners', 20, { // puedes subir hasta 20 imágenes
storage: diskStorage({
destination: './uploads/banners',
filename: (req, file, cb) => {
cb(null, file.originalname);
},
}),
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const ext = extname(file.originalname).toLowerCase();
if (allowedTypes.test(ext)) {
cb(null, true);
} else {
cb(new Error('Tipo de archivo no permitido'), false);
}
},
}),
)async subirBanners(@UploadedFiles() files: Express.Multer.File[]) {
if (!files || files.length === 0) {
throw new UnprocessableEntityException('No se subió ningún archivo');
}
// Retornamos lista de filenames para asociarlos después
return files.map((file) => ({
originalName: file.originalname,
filename: file.filename,
path: file.path,
}));
}
@Post('carga-masiva')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = './uploads/excel';
// Crear la carpeta si no existe
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: (req, file, cb) => {
// Guardar el archivo con timestamp + nombre original
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const fileExt = extname(file.originalname); // obtener extensión
cb(null, `${uniqueSuffix}${fileExt}`);
},
}),
fileFilter: (req, file, cb) => {
// Solo permitir archivos .xlsx
if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .xlsx'), false);
}
},
}),
)
async cargaMasiva(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No se ha subido ningún archivo');
}
console.log('Archivo guardado en:', file.path);
// Leer el archivo con ExcelJS
// Aquí tu lógica para procesar las hojas del Excel
const cuestionarios = await this.cuestionarioService.cargarEventosDesdeExcel(file.path); // Ejemplo de arreglo de datos procesados
// ...
if (!cuestionarios.data) {
throw error;
}
return {
message: cuestionarios.message,
total: cuestionarios.data.length,
data: cuestionarios,
};
}
@Post('withEvento')
@CuestionarioApiDocumentation.ApiCreateWithEvento
createCuestionarioEvento(
@Body() createCuestionarioDto: CreateEventoWithCuestionarioDto,
) {
return this.cuestionarioService.createCuestionarioEvento(
createCuestionarioDto,
);
}
@Post(':id/banner')
@UseInterceptors(
FileInterceptor('banner', {
storage: diskStorage({
destination: './uploads/banners', // Asegúrate de que esta carpeta exista
filename: (req, file, cb) => {
const uniqueSuffix =
Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `banner-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const ext = extname(file.originalname).toLowerCase();
if (allowedTypes.test(ext)) {
cb(null, true);
} else {
cb(new Error('Tipo de archivo no permitido'), false);
}
},
}),
)
async subirBannerEvento(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new Error('No se ha recibido un archivo válido');
}
return this.cuestionarioService.asociarBanner(id, file.filename);
}
@Get()
@CuestionarioApiDocumentation.ApiGetAll
findAll() {
return this.cuestionarioService.findAll();
}
@Get('recientes')
@CuestionarioApiDocumentation.ApiGetAll
findAllRecientes() {
return this.cuestionarioService.findAllRecientes();
}
@Get(':id')
@CuestionarioApiDocumentation.ApiGetOne
findOne(@Param('id') id: string) {
return this.cuestionarioService.findOne(+id);
}
@Get(':id/formulario')
@CuestionarioApiDocumentation.ApiGetFormulario
findFormulario(@Param('id') id: string) {
@@ -36,7 +197,10 @@ export class CuestionarioController {
@Patch(':id')
@CuestionarioApiDocumentation.ApiUpdate
update(@Param('id') id: string, @Body() updateCuestionarioDto: UpdateCuestionarioDto) {
update(
@Param('id') id: string,
@Body() updateCuestionarioDto: UpdateCuestionarioDto,
) {
return this.cuestionarioService.update(+id, updateCuestionarioDto);
}
+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 { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Evento } from '../evento/evento.entity';
import { Evento } from '../evento/entities/evento.entity';
import { EventoService } from '../evento/evento.service';
@Module({
@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CuestionarioService } from './cuestionario.service';
describe('CuestionarioService', () => {
let service: CuestionarioService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CuestionarioService],
}).compile();
service = module.get<CuestionarioService>(CuestionarioService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+571 -166
View File
@@ -1,19 +1,32 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
import { Repository, DataSource, In, MoreThan } from 'typeorm';
import {
CreateCuestionarioDto,
CreateCuestionarioEventoDto,
} from './dto/create-cuestionario.dto';
import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { Cuestionario } from './entities/cuestionario.entity';
import { Seccion } from '../seccion/entities/seccion.entity';
import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { Pregunta, TiposValidacion } from '../pregunta/entities/pregunta.entity';
import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Evento } from '../evento/evento.entity';
import {
TipoPregunta,
TipoPreguntaEnum,
} from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { Evento } from '../evento/entities/evento.entity';
import { EventoService } from '../evento/evento.service';
import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto';
import * as ExcelJS from 'exceljs';
import { PLANTILLAS } from './plantillas';
@Injectable()
export class CuestionarioService {
@@ -37,138 +50,298 @@ export class CuestionarioService {
@InjectRepository(Evento)
private eventoRepository: Repository<Evento>,
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) {
// Verificar que el evento exista
await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Verificar si se especificó un evento y búscarlo/crearlo
let idEvento: number | undefined = undefined;
if (createCuestionarioDto.evento) {
// Buscar si el evento ya existe
let evento = await this.eventoRepository.findOne({
where: { nombre_evento: createCuestionarioDto.evento }
});
// Si no existe, crearlo
if (!evento) {
const nuevoEvento = {
nombre_evento: createCuestionarioDto.evento,
tipo_evento: 'Predeterminado',
fecha_inicio: createCuestionarioDto.fecha_inicio || new Date(),
fecha_fin: createCuestionarioDto.fecha_fin || new Date(Date.now() + 86400000) // 1 día después si no hay fecha
};
evento = await queryRunner.manager.save(Evento, nuevoEvento);
console.log(`Evento creado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
} else {
console.log(`Evento encontrado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
}
idEvento = evento.id_evento;
}
// 1. Crear cuestionario
const cuestionario = new Cuestionario();
cuestionario.nombre_form = createCuestionarioDto.nombre_form;
cuestionario.descripcion = createCuestionarioDto.descripcion;
cuestionario.fecha_inicio = createCuestionarioDto.fecha_inicio ? new Date(createCuestionarioDto.fecha_inicio) : undefined;
cuestionario.fecha_fin = createCuestionarioDto.fecha_fin ? new Date(createCuestionarioDto.fecha_fin) : undefined;
cuestionario.id_tipo_cuestionario = createCuestionarioDto.id_tipo_cuestionario;
cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0;
cuestionario.fecha_inicio = new Date(createCuestionarioDto.fecha_inicio);
cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin);
cuestionario.id_tipo_cuestionario =
createCuestionarioDto.id_tipo_cuestionario;
cuestionario.id_tipo_evento = createCuestionarioDto.id_tipo_evento;
cuestionario.contador_secciones =
createCuestionarioDto.secciones?.length || 0;
cuestionario.editable = true;
// Asociar el cuestionario con el evento si existe
if (idEvento !== undefined) {
cuestionario.id_evento = idEvento;
}
cuestionario.id_evento = createCuestionarioDto.id_evento;
cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo;
const savedCuestionario = await queryRunner.manager.save(cuestionario);
// 2. Crear secciones y vincularlas al cuestionario
if (createCuestionarioDto.secciones && createCuestionarioDto.secciones.length > 0) {
// 2. Crear secciones y vincularlas
if (
createCuestionarioDto.secciones &&
createCuestionarioDto.secciones?.length > 0
) {
for (let i = 0; i < createCuestionarioDto.secciones.length; i++) {
const seccionDto = createCuestionarioDto.secciones[i];
// Crear sección
const seccion = new Seccion();
seccion.titulo = seccionDto.titulo;
seccion.descripcion = seccionDto.descripcion;
seccion.contador_pregunta = seccionDto.preguntas?.length || 0;
const savedSeccion = await queryRunner.manager.save(seccion);
// Vincular sección con cuestionario
const cuestionarioSeccion = new CuestionarioSeccion();
cuestionarioSeccion.id_cuestionario = savedCuestionario.id_cuestionario;
cuestionarioSeccion.id_cuestionario =
savedCuestionario.id_cuestionario;
cuestionarioSeccion.id_seccion = savedSeccion.id_seccion;
cuestionarioSeccion.posicion = i + 1;
await queryRunner.manager.save(cuestionarioSeccion);
// 3. Crear preguntas y vincularlas a la sección
if (seccionDto.preguntas && seccionDto.preguntas.length > 0) {
// 3. Preguntas por sección
if (seccionDto.preguntas && seccionDto.preguntas?.length > 0) {
for (let j = 0; j < seccionDto.preguntas.length; j++) {
const preguntaDto = seccionDto.preguntas[j];
// Mapear directamente el tipo de pregunta a su ID correspondiente
let idTipoPregunta = 7; // Valor por defecto (desconocido)
// Mapeamos el tipo de pregunta a su ID correspondiente
if (preguntaDto.tipo === 'Cerrada') {
idTipoPregunta = 1;
} else if (preguntaDto.tipo === 'Abierta') {
idTipoPregunta = 2;
} else if (preguntaDto.tipo === 'Multiple') {
idTipoPregunta = 3;
} else {
console.warn(`Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`);
let idTipoPregunta = 7;
switch (preguntaDto.tipo) {
case TipoPreguntaEnum.AbiertaParrafo:
idTipoPregunta = 1;
break;
case TipoPreguntaEnum.AbiertaRespuestaCorta:
idTipoPregunta = 2;
break;
case TipoPreguntaEnum.Cerrada:
idTipoPregunta = 3;
break;
case TipoPreguntaEnum.Multiple:
idTipoPregunta = 4;
break;
default:
console.warn(
`Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`,
);
idTipoPregunta = 7;
break;
}
const tipoPregunta = { id_tipo: idTipoPregunta, tipo_pregunta: preguntaDto.tipo };
// Crear pregunta
const pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo;
pregunta.obligatoria = preguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined;
pregunta.id_tipo_pregunta = idTipoPregunta;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
pregunta.validacion = preguntaDto.validacion || undefined;
const savedPregunta = await queryRunner.manager.save(pregunta);
// Vincular pregunta con sección
const seccionPregunta = new SeccionPregunta();
seccionPregunta.id_seccion = savedSeccion.id_seccion;
seccionPregunta.id_pregunta = savedPregunta.id_pregunta;
seccionPregunta.posicion = j + 1;
await queryRunner.manager.save(seccionPregunta);
// 4. Crear opciones para preguntas de tipo multiple/radio
if (preguntaDto.opciones && preguntaDto.opciones.length > 0) {
// 4. Crear opciones si las hay
if (preguntaDto.opciones && preguntaDto.opciones?.length > 0) {
for (let k = 0; k < preguntaDto.opciones.length; k++) {
const opcionDto = preguntaDto.opciones[k];
// Crear opción
const opcion = new Opcion();
opcion.opcion = opcionDto.valor;
const savedOpcion = await queryRunner.manager.save(opcion);
// Vincular opción con pregunta
const preguntaOpcion = new PreguntaOpcion();
preguntaOpcion.id_pregunta = savedPregunta.id_pregunta;
// Utilizamos la opción como objeto, no como ID
preguntaOpcion.opcion = savedOpcion;
preguntaOpcion.posicion = k + 1;
await queryRunner.manager.save(preguntaOpcion);
}
}
@@ -178,10 +351,194 @@ export class CuestionarioService {
}
await queryRunner.commitTransaction();
return {
success: true,
cuestionario: savedCuestionario,
message: 'Cuestionario creado exitosamente',
id_cuestionario: savedCuestionario.id_cuestionario,
id_evento: savedCuestionario.id_evento,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException(
'Error al crear el cuestionario y evento',
error.message,
);
} finally {
await queryRunner.release();
}
}
async findAllRecientes() {
console.log('Buscando cuestionarios recientes');
const now = new Date();
const pastDate = new Date();
pastDate.setDate(now.getDate() - 30);
return this.cuestionarioRepository.find({
where: {
fecha_inicio: MoreThan(pastDate),
},
order: { fecha_fin: 'DESC' },
});
}
async createCuestionarioEvento(
createCuestionarioDto: CreateEventoWithCuestionarioDto,
) {
const { evento, cuestionario } = createCuestionarioDto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
/*
// Buscar si ya existe un evento con el nombre proporcionado
let eventoExistente = await this.eventoRepository.findOne({
where: { nombre_evento: evento.nombre_evento },
});
**/
// Si el evento no existe, crearlo
//if (!eventoExistente) {
let eventoExistente = queryRunner.manager.create(Evento, evento);
await queryRunner.manager.save(eventoExistente);
console.log(`Creando nuevo evento: ${evento.nombre_evento}`);
//}
// Crear el cuestionario asociado al evento
const cuestionarioEntity = queryRunner.manager.create(Cuestionario, {
nombre_form: cuestionario.nombre_form,
descripcion: cuestionario.descripcion,
fecha_inicio: new Date(cuestionario.fecha_inicio),
fecha_fin: new Date(cuestionario.fecha_fin),
id_tipo_cuestionario: cuestionario.id_tipo_cuestionario,
id_tipo_evento: cuestionario.id_tipo_evento,
contador_secciones: cuestionario.secciones?.length || 0,
editable: true,
id_evento: eventoExistente.id_evento, // Asociar el evento creado
cupo_maximo: cuestionario.cupo_maximo,
});
const savedCuestionario =
await queryRunner.manager.save(cuestionarioEntity);
console.log(
`Cuestionario creado: ${savedCuestionario.nombre_form}, ID: ${savedCuestionario.id_cuestionario}`,
);
// Crear las secciones del cuestionario
if (
createCuestionarioDto.cuestionario.secciones &&
createCuestionarioDto.cuestionario.secciones.length > 0
) {
for (
let i = 0;
i < createCuestionarioDto.cuestionario.secciones.length;
i++
) {
const seccionDto = createCuestionarioDto.cuestionario.secciones[i];
// Crear sección
const seccion = new Seccion();
seccion.titulo = seccionDto.titulo;
seccion.descripcion = seccionDto.descripcion;
seccion.contador_pregunta = seccionDto.preguntas?.length || 0;
const savedSeccion = await queryRunner.manager.save(seccion);
// Vincular sección con cuestionario
const cuestionarioSeccion = new CuestionarioSeccion();
cuestionarioSeccion.id_cuestionario =
savedCuestionario.id_cuestionario;
cuestionarioSeccion.id_seccion = savedSeccion.id_seccion;
cuestionarioSeccion.posicion = i + 1;
await queryRunner.manager.save(cuestionarioSeccion);
// 3. Crear preguntas y vincularlas a la sección
if (seccionDto.preguntas && seccionDto.preguntas.length > 0) {
for (let j = 0; j < seccionDto.preguntas.length; j++) {
const preguntaDto = seccionDto.preguntas[j];
let idTipoPregunta = 7;
switch (preguntaDto.tipo) {
case TipoPreguntaEnum.AbiertaParrafo:
idTipoPregunta = 1;
break;
case TipoPreguntaEnum.AbiertaRespuestaCorta:
idTipoPregunta = 2;
break;
case TipoPreguntaEnum.Cerrada:
idTipoPregunta = 3;
break;
case TipoPreguntaEnum.Multiple:
idTipoPregunta = 4;
break;
default:
console.warn(
`Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`,
);
idTipoPregunta = 7;
break;
}
const tipoPregunta = {
id_tipo: idTipoPregunta,
tipo_pregunta: preguntaDto.tipo,
};
// Crear pregunta
const pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo;
pregunta.obligatoria = preguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
pregunta.validacion = preguntaDto.validacion || undefined;
const savedPregunta = await queryRunner.manager.save(pregunta);
// Vincular pregunta con sección
const seccionPregunta = new SeccionPregunta();
seccionPregunta.id_seccion = savedSeccion.id_seccion;
seccionPregunta.id_pregunta = savedPregunta.id_pregunta;
seccionPregunta.posicion = j + 1;
await queryRunner.manager.save(seccionPregunta);
// 4. Crear opciones para preguntas de tipo multiple/radio
if (preguntaDto.opciones && preguntaDto.opciones.length > 0) {
for (let k = 0; k < preguntaDto.opciones.length; k++) {
const opcionDto = preguntaDto.opciones[k];
// Crear opción
const opcion = new Opcion();
opcion.opcion = opcionDto.valor;
const savedOpcion = await queryRunner.manager.save(opcion);
// Vincular opción con pregunta
const preguntaOpcion = new PreguntaOpcion();
preguntaOpcion.id_pregunta = savedPregunta.id_pregunta;
// Utilizamos la opción como objeto, no como ID
preguntaOpcion.opcion = savedOpcion;
preguntaOpcion.posicion = k + 1;
await queryRunner.manager.save(preguntaOpcion);
}
}
}
}
}
}
await queryRunner.commitTransaction();
return {
success: true,
message: 'Cuestionario y evento creados exitosamente',
id_cuestionario: savedCuestionario.id_cuestionario,
id_evento: eventoExistente.id_evento,
};
} catch (error) {
await queryRunner.rollbackTransaction();
@@ -191,106 +548,144 @@ export class CuestionarioService {
}
}
async asociarBanner(id: number, banner: string) {
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id },
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`);
}
// Actualizar el banner del cuestionario
cuestionario.banner = banner;
return this.cuestionarioRepository.save(cuestionario);
}
async getCuestionarioOrFail(id_cuestionario: number): Promise<Cuestionario> {
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id_cuestionario },
relations: ['evento'],
});
if (!cuestionario) {
throw new NotFoundException(
`Cuestionario con ID ${id_cuestionario} no encontrado`,
);
}
return cuestionario;
}
findAll() {
return this.cuestionarioRepository.find();
}
async findOne(id: number) {
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id }
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id },
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`);
}
return cuestionario;
}
async findCompleto(id: number) {
// Obtener el cuestionario básico
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id },
relations: ['evento'] // Incluir la relación con el evento
relations: ['evento'], // Incluir la relación con el evento
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`);
}
// Para fines de demostración, usar valores hardcodeados
// para el tipo de cuestionario según get_formulario_feria.ts
const tipoCuestionario = {
id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta'
tipo_cuestionario: 'Encuesta',
};
// Mapeador de ID de tipo de pregunta a nombres conocidos
const tiposPreguntaMap = {
1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
1: { id_tipo: 1, tipo_pregunta: TipoPreguntaEnum.AbiertaParrafo },
2: { id_tipo: 2, tipo_pregunta: TipoPreguntaEnum.AbiertaRespuestaCorta },
3: { id_tipo: 3, tipo_pregunta: TipoPreguntaEnum.Cerrada },
4: { id_tipo: 4, tipo_pregunta: TipoPreguntaEnum.Multiple },
};
// Obtener las relaciones cuestionario-seccion
const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find({
where: { id_cuestionario: id },
order: { posicion: 'ASC' }
});
const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find(
{
where: { id_cuestionario: id },
order: { posicion: 'ASC' },
},
);
// Obtener todas las secciones
const seccionIds = cuestionarioSecciones.map(cs => cs.id_seccion);
const seccionIds = cuestionarioSecciones.map((cs) => cs.id_seccion);
const secciones = await this.seccionRepository.find({
where: { id_seccion: In(seccionIds) }
where: { id_seccion: In(seccionIds) },
});
const seccionesFormateadas = await Promise.all(
cuestionarioSecciones.map(async (cs) => {
const seccion = secciones.find(s => s.id_seccion === cs.id_seccion);
const seccion = secciones.find((s) => s.id_seccion === cs.id_seccion);
if (!seccion) return null;
// Obtener relaciones sección-pregunta
const seccionPreguntas = await this.seccionPreguntaRepository.find({
where: { id_seccion: seccion.id_seccion },
order: { posicion: 'ASC' }
order: { posicion: 'ASC' },
});
// Obtener preguntas
const preguntaIds = seccionPreguntas.map(sp => sp.id_pregunta);
const preguntaIds = seccionPreguntas.map((sp) => sp.id_pregunta);
const preguntas = await this.preguntaRepository.find({
where: { id_pregunta: In(preguntaIds) }
where: { id_pregunta: In(preguntaIds) },
});
// Formatear cada pregunta según el formato requerido
const preguntasFormateadas = await Promise.all(
seccionPreguntas.map(async (sp) => {
const pregunta = preguntas.find(p => p.id_pregunta === sp.id_pregunta);
const pregunta = preguntas.find(
(p) => p.id_pregunta === sp.id_pregunta,
);
if (!pregunta) return null;
// Usar el mapeo de tipos de pregunta definido arriba
const tipoPregunta = tiposPreguntaMap[pregunta.id_tipo_pregunta] ||
{ id_tipo: pregunta.id_tipo_pregunta, tipo_pregunta: 'Desconocido' };
const tipoPregunta = tiposPreguntaMap[
pregunta.id_tipo_pregunta
] || {
id_tipo: pregunta.id_tipo_pregunta,
tipo_pregunta: 'Desconocido',
};
// Obtener opciones para preguntas de tipo multiple/radio
const preguntaOpciones = await this.preguntaOpcionRepository.find({
where: { id_pregunta: pregunta.id_pregunta },
relations: ['opcion'],
order: { posicion: 'ASC' }
order: { posicion: 'ASC' },
});
// Formatear las opciones según el formato requerido
const opcionesFormateadas = preguntaOpciones.map(po => ({
const opcionesFormateadas = preguntaOpciones.map((po) => ({
id_pregunta_opcion: po.id_pregunta_opcion,
posicion: po.posicion,
id_opcion: po.opcion.id_opcion,
opcion: {
id_opcion: po.opcion.id_opcion,
opcion: po.opcion.opcion
}
opcion: po.opcion.opcion,
},
}));
// Construir objeto de pregunta según el formato requerido
return {
id_seccion_pregunta: sp.id_seccion_pregunta,
@@ -301,18 +696,17 @@ export class CuestionarioService {
contador_opcion: pregunta.contador_opcion,
obligatoria: pregunta.obligatoria,
id_tipo_pregunta: pregunta.id_tipo_pregunta,
id_opcion_dependiente: pregunta.id_opcion_dependiente,
validacion: pregunta.validacion,
tipo_pregunta: {
id_tipo: tipoPregunta.id_tipo,
tipo_pregunta: tipoPregunta.tipo_pregunta
tipo_pregunta: tipoPregunta.tipo_pregunta,
},
opciones: opcionesFormateadas
}
opciones: opcionesFormateadas,
},
};
})
).then(results => results.filter(p => p !== null));
}),
).then((results) => results.filter((p) => p !== null));
// Construir objeto de sección según el formato requerido
return {
id_cuestionario_seccion: cs.id_cuestionario_seccion,
@@ -321,39 +715,50 @@ export class CuestionarioService {
id_seccion: seccion.id_seccion,
contador_pregunta: seccion.contador_pregunta,
descripcion: seccion.descripcion,
titulo: seccion.titulo
titulo: seccion.titulo,
},
preguntas: preguntasFormateadas
preguntas: preguntasFormateadas,
};
})
).then(results => results.filter(s => s !== null));
}),
).then((results) => results.filter((s) => s !== null));
// Construir objeto final respetando el formato de get_formulario_feria.ts
return {
tipo_cuestionario: {
id_tipo_cuestionario: tipoCuestionario.id_tipo_cuestionario,
tipo_cuestionario: tipoCuestionario.tipo_cuestionario
tipo_cuestionario: tipoCuestionario.tipo_cuestionario,
},
evento: cuestionario.evento ? {
id_evento: cuestionario.evento.id_evento,
nombre_evento: cuestionario.evento.nombre_evento,
tipo_evento: cuestionario.evento.tipo_evento,
fecha_inicio: cuestionario.evento.fecha_inicio ? cuestionario.evento.fecha_inicio.toISOString() : null,
fecha_fin: cuestionario.evento.fecha_fin ? cuestionario.evento.fecha_fin.toISOString() : null
} : null,
evento: cuestionario.evento
? {
id_evento: cuestionario.evento.id_evento,
nombre_evento: cuestionario.evento.nombre_evento,
descripcion_evento: cuestionario.evento.descripcion_evento,
banner: cuestionario.evento.banner,
tipo_evento: cuestionario.evento.tipo_evento,
fecha_inicio: cuestionario.evento.fecha_inicio
? cuestionario.evento.fecha_inicio.toISOString()
: null,
fecha_fin: cuestionario.evento.fecha_fin
? cuestionario.evento.fecha_fin.toISOString()
: null,
}
: null,
cuestionario: {
id_cuestionario: cuestionario.id_cuestionario,
nombre_form: cuestionario.nombre_form,
contador_secciones: cuestionario.contador_secciones,
descripcion: cuestionario.descripcion,
editable: cuestionario.editable,
fecha_inicio: cuestionario.fecha_inicio ? cuestionario.fecha_inicio.toISOString() : null,
fecha_fin: cuestionario.fecha_fin ? cuestionario.fecha_fin.toISOString() : null,
id_cuestionario_original: cuestionario.id_cuestionario_original,
fecha_inicio: cuestionario.fecha_inicio
? cuestionario.fecha_inicio.toISOString()
: null,
fecha_fin: cuestionario.fecha_fin
? cuestionario.fecha_fin.toISOString()
: null,
id_tipo_cuestionario: cuestionario.id_tipo_cuestionario,
id_evento: cuestionario.id_evento || null,
secciones: seccionesFormateadas
}
secciones: seccionesFormateadas,
},
};
}
@@ -362,31 +767,31 @@ export class CuestionarioService {
if (updateCuestionarioDto.evento) {
// Buscar o crear evento
let evento = await this.eventoRepository.findOne({
where: { nombre_evento: updateCuestionarioDto.evento }
where: { nombre_evento: updateCuestionarioDto.evento },
});
if (!evento) {
// Crear el evento
const nuevoEvento = {
nombre_evento: updateCuestionarioDto.evento,
tipo_evento: 'Predeterminado',
fecha_inicio: new Date(),
fecha_fin: new Date(Date.now() + 86400000)
fecha_fin: new Date(Date.now() + 86400000),
};
evento = await this.eventoRepository.save(nuevoEvento);
}
// Asignar el ID del evento al DTO de actualización
updateCuestionarioDto.id_evento = evento.id_evento;
// Eliminar la propiedad evento para evitar conflictos
delete updateCuestionarioDto.evento;
}
// Preparar los datos para la actualización
const updateData: any = { ...updateCuestionarioDto };
return this.cuestionarioRepository.update(id, updateData);
}
@@ -1,87 +1,82 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
import { feriaSexualidad } from '../../utils/crear_formulario_feria';
import {
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
/* import { feriaSexualidad } from '../../../utils/crear_formulario_feria'; */
import { applyDecorators } from '@nestjs/common';
import { FormularioDto, FormularioFeriaSchema } from './dto/formulario.schema';
import { FormularioDto } from '../dto/formulario.schema';
import {
CreateCuestionarioDto,
CreateCuestionarioEventoDto,
} from '../dto/create-cuestionario.dto';
import { ejemploCreateEventoWithCuestionario, ejemploCuestionarioAlumno } from './cuestionario.examples';
export class CuestionarioApiDocumentation {
// Decoradores para toda la clase del controlador
static ApiController = ApiTags('Cuestionarios');
static ApiController = ApiTags('Cuestionario');
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Crear un nuevo cuestionario para un evento existente',
description:
'Este endpoint crea un cuestionario con sus secciones, preguntas y opciones. Se debe proporcionar el ID de un evento existente para asociarlo.',
}),
ApiBody({
type: CreateCuestionarioDto,
examples: {
comunidad_estudiantil: ejemploCuestionarioAlumno,
},
}),
);
// Documentación para obtener el formulario completo
static ApiGetFormulario = applyDecorators(
ApiOperation({
summary: 'Obtener formulario completo',
description: 'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo, incluyendo el atributo validacion para las preguntas que tengan reglas de validación específicas'
description:
'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo, incluyendo el atributo validacion para las preguntas que tengan reglas de validación específicas',
}),
ApiParam({
name: 'id',
description: 'ID del cuestionario',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
description: 'Formulario completo con todas sus secciones, preguntas y opciones. Las preguntas incluyen el atributo "validacion" si tienen reglas de validación específicas (como "correo", "cuenta_alumno", "telefono", "nombre", etc.)',
type: FormularioDto,
content: {
'application/json': {
schema: { $ref: getSchemaPath(FormularioDto) },
example: feriaSexualidad
}
}
}),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' })
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }),
);
// Documentación para crear un cuestionario
static ApiCreate = applyDecorators(
static ApiCreateWithEvento = applyDecorators(
ApiOperation({
summary: 'Crear un nuevo cuestionario',
description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones, incluyendo validaciones personalizadas'
summary: 'Crear un nuevo cuestionario con nuevo evento',
description:
'Este endpoint crea un cuestionario y también un evento (si no existe) usando el nombre proporcionado. No se requiere `id_evento`, sólo `evento` (nombre del evento, es opcional).',
}),
ApiResponse({
status: 201,
description: 'Cuestionario y evento creados exitosamente',
}),
ApiResponse({
status: 400,
description: 'Datos inválidos',
}),
ApiBody({
description: 'Datos del cuestionario a crear. Para las preguntas de tipo "Abierta", se puede especificar un tipo de validación usando la propiedad "validacion" con valores como: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
type: FormularioDto,
type: CreateCuestionarioEventoDto,
examples: {
feriaSexualidad: {
summary: 'Ejemplo de cuestionario para feria de sexualidad',
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad, ahora con validaciones específicas para cada tipo de campo (correo, nombre, cuenta_alumno, etc.)',
value: feriaSexualidad
}
}
ejemplo_con_evento_nuevo: ejemploCreateEventoWithCuestionario,
},
}),
ApiResponse({
status: 201,
description: 'Cuestionario creado correctamente. Los campos con la propiedad "validacion" se validarán según su tipo: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
schema: {
properties: {
success: { type: 'boolean', example: true },
cuestionario: {
type: 'object',
properties: {
id_cuestionario: { type: 'number', example: 1 },
nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
descripcion: { type: 'string' },
contador_secciones: { type: 'number', example: 1 },
editable: { type: 'boolean', example: true },
fecha_fin: { type: 'string', format: 'date-time' },
fecha_inicio: { type: 'string', format: 'date-time' },
id_tipo_cuestionario: { type: 'number', example: 1 }
}
}
}
}
}),
ApiResponse({ status: 400, description: 'Datos del cuestionario inválidos' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
);
// Documentación para obtener todos los cuestionarios
static ApiGetAll = applyDecorators(
ApiOperation({
summary: 'Obtener todos los cuestionarios',
description: 'Retorna una lista de todos los cuestionarios registrados'
description: 'Retorna una lista de todos los cuestionarios registrados',
}),
ApiResponse({
status: 200,
@@ -92,32 +87,35 @@ export class CuestionarioApiDocumentation {
type: 'object',
properties: {
id_cuestionario: { type: 'number', example: 1 },
nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
nombre_form: {
type: 'string',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán',
},
descripcion: { type: 'string' },
contador_secciones: { type: 'number', example: 1 },
editable: { type: 'boolean', example: true },
fecha_fin: { type: 'string', format: 'date-time' },
fecha_inicio: { type: 'string', format: 'date-time' },
id_tipo_cuestionario: { type: 'number', example: 1 }
}
}
}
id_tipo_cuestionario: { type: 'number', example: 1 },
},
},
},
}),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para obtener un cuestionario por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener un cuestionario por ID',
description: 'Retorna un cuestionario específico por su ID'
description: 'Retorna un cuestionario específico por su ID',
}),
ApiParam({
name: 'id',
description: 'ID del cuestionario',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -126,45 +124,51 @@ export class CuestionarioApiDocumentation {
type: 'object',
properties: {
id_cuestionario: { type: 'number', example: 1 },
nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' },
nombre_form: {
type: 'string',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán',
},
descripcion: { type: 'string' },
contador_secciones: { type: 'number', example: 1 },
editable: { type: 'boolean', example: true },
fecha_fin: { type: 'string', format: 'date-time' },
fecha_inicio: { type: 'string', format: 'date-time' },
id_tipo_cuestionario: { type: 'number', example: 1 }
}
}
id_tipo_cuestionario: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para actualizar un cuestionario
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar un cuestionario',
description: 'Actualiza los datos de un cuestionario existente'
description: 'Actualiza los datos de un cuestionario existente',
}),
ApiParam({
name: 'id',
description: 'ID del cuestionario a actualizar',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiBody({
description: 'Datos a actualizar del cuestionario',
schema: {
type: 'object',
properties: {
nombre_form: { type: 'string', example: 'Nombre actualizado del formulario' },
nombre_form: {
type: 'string',
example: 'Nombre actualizado del formulario',
},
descripcion: { type: 'string', example: 'Descripción actualizada' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' },
editable: { type: 'boolean', example: true }
}
}
editable: { type: 'boolean', example: true },
},
},
}),
ApiResponse({
status: 200,
@@ -172,27 +176,30 @@ export class CuestionarioApiDocumentation {
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({
status: 400,
description: 'Datos de actualización inválidos',
}),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para eliminar un cuestionario
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar un cuestionario',
description: 'Elimina permanentemente un cuestionario por su ID'
description: 'Elimina permanentemente un cuestionario por su ID',
}),
ApiParam({
name: 'id',
description: 'ID del cuestionario a eliminar',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -200,11 +207,11 @@ export class CuestionarioApiDocumentation {
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
}
}
@@ -0,0 +1,224 @@
import { ejemploFeriaSexualidad } from 'src/evento/docs/evento.examples';
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
export const ejemploCreateEventoWithCuestionario = {
summary: 'Crear un evento con cuestionario asociado',
value: {
evento: ejemploFeriaSexualidad.value,
cuestionario: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
cupo_maximo: 100,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_ALUMNO,
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: TiposValidacion.CUENTA_ALUMNO,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.CORREO,
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.NOMBRE,
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.APELLIDOS,
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.INSTITUCION,
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.CARRERA,
},
],
},
],
},
},
};
export const ejemploCuestionarioAlumno = {
summary:
'Cuestionario para el registro de asistencia a un evento para comunidad estudiantil',
value: {
id_evento: 1,
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
cupo_maximo: 100,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_ALUMNO,
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: TiposValidacion.CUENTA_ALUMNO,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.CORREO,
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.NOMBRE,
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.APELLIDOS,
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.INSTITUCION,
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.CARRERA,
},
],
},
],
},
};
export const ejemploCuestionarioTrabajador = {
summary:
'Cuestionario para el registro de asistencia a un evento para comunidad trabajadora',
value: {
id_evento: 1,
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 2,
cupo_maximo: 100,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_TRABAJADOR,
},
{
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: TiposValidacion.RFC,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.CORREO_INSTITUCIONAL,
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.NOMBRE,
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: TiposValidacion.NOMBRE,
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
],
},
],
},
};
+51 -13
View File
@@ -1,12 +1,30 @@
import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto';
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { OmitType } from '@nestjs/mapped-types';
export class CreateCuestionarioDto {
@ApiProperty({
description: 'ID del evento al que pertenece el cuestionario',
example: 1,
required: false,
})
@IsNotEmpty()
@IsNumber()
id_evento: number;
@ApiProperty({
description: 'Nombre del formulario',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
example: 'Registro para la Feria de la Sexualidad - FES Acatlán',
})
@IsNotEmpty()
@IsString()
@@ -15,7 +33,7 @@ export class CreateCuestionarioDto {
@ApiProperty({
description: 'Descripción del formulario',
example: 'Formulario de registro para la feria...',
required: false
required: false,
})
@IsOptional()
@IsString()
@@ -24,33 +42,48 @@ export class CreateCuestionarioDto {
@ApiProperty({
description: 'Fecha de inicio',
example: '2025-03-26T00:00:00',
required: false
required: false,
})
@IsOptional()
@IsDateString()
fecha_inicio?: Date;
fecha_inicio: Date;
@ApiProperty({
description: 'Fecha de fin',
example: '2025-03-31T23:59:59',
required: false
required: false,
})
@IsOptional()
@IsDateString()
fecha_fin?: Date;
fecha_fin: Date;
@ApiProperty({
description: 'ID del tipo de cuestionario',
example: 1
example: 1,
})
@IsNotEmpty()
@IsNumber()
id_tipo_cuestionario: number;
@ApiProperty({
description: 'ID del tipo de evento',
example: 1,
})
@IsOptional()
@IsNumber()
id_tipo_evento: number;
@ApiProperty({
description: 'Cupo máximo de participantes',
example: 100,
required: false,
})
@IsOptional()
@IsNumber()
cupo_maximo?: number;
@ApiProperty({
description: 'Nombre del evento asociado',
example: 'Feria de la Sexualidad',
required: false
required: false,
})
@IsOptional()
@IsString()
@@ -59,7 +92,7 @@ export class CreateCuestionarioDto {
@ApiProperty({
description: 'Secciones del cuestionario',
type: [CreateSeccionDto],
required: false
required: false,
})
@IsOptional()
@IsArray()
@@ -67,3 +100,8 @@ export class CreateCuestionarioDto {
@Type(() => CreateSeccionDto)
secciones?: CreateSeccionDto[];
}
export class CreateCuestionarioEventoDto extends OmitType(
CreateCuestionarioDto,
['id_evento'] as const,
) {}
@@ -0,0 +1,7 @@
import { CreateEventoDto } from 'src/evento/dto/create-evento.dto';
import { CreateCuestionarioDto, CreateCuestionarioEventoDto } from './create-cuestionario.dto';
export interface CreateEventoWithCuestionarioDto {
evento: CreateEventoDto;
cuestionario: CreateCuestionarioEventoDto;
}
@@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { TipoPreguntaEnum } from '../../pregunta/dto/create-pregunta.dto';
// Ejemplo de respuesta de cuestionario completo
export const FormularioFeriaSchema = {
@@ -37,7 +36,6 @@ export const FormularioFeriaSchema = {
contador_opcion: 2,
obligatoria: true,
id_tipo_pregunta: 1,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 1,
tipo_pregunta: 'Cerrada'
@@ -73,7 +71,6 @@ export const FormularioFeriaSchema = {
contador_opcion: 0,
obligatoria: true,
id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'correo', // Validación de formato de correo electrónico
tipo_pregunta: {
@@ -92,7 +89,6 @@ export const FormularioFeriaSchema = {
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
id_opcion_dependiente: 90001, // Dependiente de la respuesta "Sí" en la pregunta anterior
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'cuenta_alumno', // Validación de formato de número de cuenta
tipo_pregunta: {
@@ -123,7 +119,6 @@ export const FormularioFeriaSchema = {
contador_opcion: 5,
obligatoria: true,
id_tipo_pregunta: 3,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 3,
tipo_pregunta: 'Multiple'
@@ -186,7 +181,6 @@ export const FormularioFeriaSchema = {
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: {
id_tipo: 2,
@@ -1,9 +1,17 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Evento } from 'src/evento/evento.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity';
import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne, JoinColumn } from 'typeorm';
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
@Entity('cuestionario')
export class Cuestionario {
@@ -13,6 +21,9 @@ export class Cuestionario {
@Column({ type: 'text' })
nombre_form: string;
@Column({ type: 'text', nullable: true })
banner: string;
@Column({ type: 'int' })
contador_secciones: number;
@@ -22,29 +33,53 @@ export class Cuestionario {
@Column({ type: 'boolean', default: true })
editable: boolean;
@Column({ type: 'datetime', nullable: true })
fecha_fin?: Date;
@Column({ type: 'datetime' })
fecha_inicio: Date;
@Column({ type: 'datetime', nullable: true })
fecha_inicio?: Date;
@OneToMany(() => CuestionarioSeccion,(cuestionario_seccion) => cuestionario_seccion.id_cuestionario)
cuestionarioSeccion:CuestionarioSeccion[]
@Column({ type: 'datetime' })
fecha_fin: Date;
@Column({ type: 'int', nullable: true })
id_cuestionario_original: number;
cupo_maximo?: number;
@Column({ type: 'int' })
id_tipo_cuestionario: number;
@ManyToOne(()=> Cuestionario)
cuestionarioOriginal: Cuestionario;
@Column({ type: 'int', nullable: true })
id_tipo_evento: number;
@Column({ type: 'int' })
id_evento: number;
@ManyToOne(() => Evento)
// Relaciones
@ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true })
@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
@@ -0,0 +1,165 @@
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
import {
TipoPreguntaEnum,
} from '../tipo_pregunta/entities/tipo_pregunta.entity';
export const PLANTILLAS = [
{
imagen: '/plantilla_trabajadores.png',
nombre: 'Registro Comunidad Trabajador',
id: 'registro-comunidad-trabajadores',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 2,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_TRABAJADOR,
},
{ titulo: 'RFC', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.RFC },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Género',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA },
],
},
],
},
},
{
imagen: '/plantilla_alumnos.png',
nombre: 'Registro Comunidad Alumnos',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_ALUMNO,
},
{ titulo: 'Numero de cuenta', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.CUENTA_ALUMNO },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Género',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Institución de procedencia', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.INSTITUCION },
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA },
],
},
],
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General',
id: 'registro-general',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{ titulo: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Tipo de Asistente',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
],
},
],
},
],
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General Externos',
id: 'registro-general-externos',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{ titulo: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Tipo de Asistente',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
{ valor: 'Externo'},
],
},
],
},
],
},
},
];
@@ -1,20 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CuestionarioRespondidoController } from './cuestionario_respondido.controller';
import { CuestionarioRespondidoService } from './cuestionario_respondido.service';
describe('CuestionarioRespondidoController', () => {
let controller: CuestionarioRespondidoController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService],
}).compile();
controller = module.get<CuestionarioRespondidoController>(CuestionarioRespondidoController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -18,17 +18,7 @@ export class CuestionarioRespondidoController {
}
@Post('submit')
@CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestas
@ApiBody({
type: SubmitRespuestasDto,
examples: {
comunidad: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.communityExample,
externos: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.externalExample
}
})
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse201
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse400
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse404
@CuestionarioRespondidoApiDocumentation.ApiSubmit
submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) {
return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto);
}
@@ -1,26 +1,81 @@
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
export const ejemploEnvioRespuestas = {
summary: 'Ejemplo de envío de respuestas a un formulario',
value: {
id_cuestionario: 1,
correo: 'miguel@acatlan.unam.mx',
fecha_envio: '2025-07-10T10:15:00', // opcional
respuestas: [
{
id_pregunta: 1, // ¿Eres parte de la comunidad de la FES Acatlán?
valor: 1, // Si (id_opcion: 1)
},
{
id_pregunta: 2, // Numero de cuenta
valor: '123456789', // texto
},
{
id_pregunta: 3, // Correo electrónico
valor: 'miguel@acatlan.unam.mx',
},
{
id_pregunta: 4, // Nombre(s)
valor: 'Liliana',
},
{
id_pregunta: 5, // Apellidos
valor: 'Alvarado Díaz',
},
{
id_pregunta: 6, // Género
valor: 4, // Femenino (id_opcion: 4)
},
{
id_pregunta: 7, // Institución de procedencia
valor: 'FES Acatlán',
},
{
id_pregunta: 8, // Carrera
valor: 'Matemáticas Aplicadas y Computación',
},
],
},
};
export class CuestionarioRespondidoApiDocumentation {
static ApiController = ApiTags('Cuestionario Respondido');
static ApiSubmitRespuestas = ApiOperation({
summary: 'Enviar respuestas a un cuestionario',
description: `Registra las respuestas de un participante a un cuestionario. Crea un registro de participante si no existe uno con ese correo.
## Tipos de respuestas:
- **Preguntas abiertas**: Enviar el texto de la respuesta como string (máximo 250 caracteres)
- **Preguntas cerradas**: Enviar el ID de la opción como número
- **Preguntas de selección múltiple**: Enviar un array de IDs de opciones como [número, número, ...]
### Importante
Para preguntas cerradas y múltiples, se deben enviar los IDs de las opciones, NO los textos.
Esto evita conflictos cuando existen opciones con el mismo texto pero diferentes IDs.
Todas las respuestas de texto y el correo electrónico están limitados a 250 caracteres.`
});
static ApiSubmit = applyDecorators(
ApiOperation({
summary: 'Enviar respuestas a un cuestionario',
description: `Este endpoint permite a un participante enviar respuestas a un cuestionario previamente creado.
Se asocia el formulario con el participante mediante su correo electrónico. Si es la primera vez que responde,
se registra automáticamente.
Las respuestas pueden ser de tipo:
- Abierta (texto)
- Cerrada (una opción)
- Múltiple (varias opciones)
Además, si el cuestionario está ligado a un evento, se registra su participación en dicho evento y se le envía un correo con un código QR para validar asistencia.`,
}),
ApiBody({
type: SubmitRespuestasDto,
examples: {
envio_respuestas: ejemploEnvioRespuestas,
},
}),
);
static ApiSubmitResponse201 = ApiResponse({
status: 201,
@@ -28,10 +83,13 @@ export class CuestionarioRespondidoApiDocumentation {
schema: {
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: 'Respuestas guardadas correctamente' },
cuestionarioRespondidoId: { type: 'number', example: 1 }
}
}
message: {
type: 'string',
example: 'Respuestas guardadas correctamente',
},
cuestionarioRespondidoId: { type: 'number', example: 1 },
},
},
});
static ApiSubmitResponse400 = ApiResponse({
@@ -40,10 +98,13 @@ export class CuestionarioRespondidoApiDocumentation {
schema: {
properties: {
statusCode: { type: 'number', example: 400 },
message: { type: 'string', example: 'La opción con ID 999 no está asociada a la pregunta 101' },
error: { type: 'string', example: 'Bad Request' }
}
}
message: {
type: 'string',
example: 'La opción con ID 999 no está asociada a la pregunta 101',
},
error: { type: 'string', example: 'Bad Request' },
},
},
});
static ApiSubmitResponse404 = ApiResponse({
@@ -52,10 +113,13 @@ export class CuestionarioRespondidoApiDocumentation {
schema: {
properties: {
statusCode: { type: 'number', example: 404 },
message: { type: 'string', example: 'Cuestionario con ID 999 no encontrado' },
error: { type: 'string', example: 'Not Found' }
}
}
message: {
type: 'string',
example: 'Cuestionario con ID 999 no encontrado',
},
error: { type: 'string', example: 'Not Found' },
},
},
});
static get ApiReporteRespuestas() {
@@ -74,16 +138,17 @@ export class CuestionarioRespondidoApiDocumentation {
- Cuestionario
- Pregunta
- Respuesta
`
`,
}),
ApiResponse({
status: 200,
description: 'Reporte de respuestas descargado exitosamente como archivo CSV'
description:
'Reporte de respuestas descargado exitosamente como archivo CSV',
}),
ApiResponse({
status: 404,
description: 'Cuestionario no encontrado'
})
description: 'Cuestionario no encontrado',
}),
);
}
@@ -97,14 +162,16 @@ export class CuestionarioRespondidoApiDocumentation {
{ id_pregunta: 101, valor: 3 }, // Opción "Si" (usando el ID de la opción)
{ id_pregunta: 102, valor: 'estudiante@acatlan.unam.mx' }, // Correo electrónico
{ id_pregunta: 103, valor: '123456789' }, // Numero de cuenta
{ id_pregunta: 106, valor: 7 } // Opción "Prefiero no decirlo" (usando el ID de la opción)
{ id_pregunta: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción)
],
fecha_envio: '2025-04-02T13:45:00'
fecha_envio: '2025-04-02T13:45:00',
},
description: 'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.'
description:
'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.',
},
externalExample: {
summary: 'Ejemplo de respuesta para un participante externo con selección múltiple',
summary:
'Ejemplo de respuesta para un participante externo con selección múltiple',
value: {
id_formulario: 1,
correo: 'externo@ejemplo.com',
@@ -115,11 +182,12 @@ export class CuestionarioRespondidoApiDocumentation {
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos
{ id_pregunta: 105, valor: [2, 5, 8] }, // Selección múltiple (array de IDs de opciones)
{ id_pregunta: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción)
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' } // Institución de procedencia
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' }, // Institución de procedencia
],
fecha_envio: '2025-04-02T13:45:00'
fecha_envio: '2025-04-02T13:45:00',
},
description: 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.'
}
description:
'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.',
},
};
}
}
@@ -5,7 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity';
import { CuestionarioModule } from '../cuestionario/cuestionario.module';
import { ParticipanteModule } from '../participante/participante.module';
import { Participante } from '../participante/participante.entity';
import { QrModule } from '../qr/qr.module';
import { Participante } from '../participante/entities/participante.entity';
import { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
@@ -24,14 +25,15 @@ import { ValidacionesModule } from '../validaciones/validaciones.module';
RespuestaParticipanteAbierta,
RespuestaParticipanteCerrada,
PreguntaOpcion,
Opcion
Opcion,
]),
CuestionarioModule,
ParticipanteModule,
ValidacionesModule
ValidacionesModule,
QrModule,
],
controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService],
exports: [TypeOrmModule]
exports: [TypeOrmModule],
})
export class CuestionarioRespondidoModule {}
@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CuestionarioRespondidoService } from './cuestionario_respondido.service';
describe('CuestionarioRespondidoService', () => {
let service: CuestionarioRespondidoService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CuestionarioRespondidoService],
}).compile();
service = module.get<CuestionarioRespondidoService>(CuestionarioRespondidoService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -1,11 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity';
import { Participante } from '../participante/participante.entity';
import { Participante } from '../participante/entities/participante.entity';
import { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
@@ -13,8 +17,12 @@ import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service';
import { QrTokenService } from '../qr/qr-token.service';
import axios from 'axios';
import * as QRCode from 'qrcode';
import { CuestionarioService } from 'src/cuestionario/cuestionario.service';
import { generarHtmlCorreoAsistencia } from 'src/emails/registro-evento';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
@Injectable()
export class CuestionarioRespondidoService {
constructor(
@@ -26,16 +34,13 @@ export class CuestionarioRespondidoService {
private cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(Pregunta)
private preguntaRepository: Repository<Pregunta>,
@InjectRepository(RespuestaParticipanteAbierta)
private respuestaAbiertaRepository: Repository<RespuestaParticipanteAbierta>,
@InjectRepository(RespuestaParticipanteCerrada)
private respuestaCerradaRepository: Repository<RespuestaParticipanteCerrada>,
@InjectRepository(PreguntaOpcion)
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
@InjectRepository(Opcion)
private opcionRepository: Repository<Opcion>,
private dataSource: DataSource,
private validadorRespuestasService: ValidadorRespuestasService,
private cuestionarioService: CuestionarioService,
private qrTokenService: QrTokenService,
) {}
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
@@ -49,67 +54,183 @@ export class CuestionarioRespondidoService {
try {
// 1. Verificar que el cuestionario existe
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: submitDto.id_formulario },
relations: ['evento']
});
const cuestionario = await this.cuestionarioService.getCuestionarioOrFail(
submitDto.id_cuestionario,
);
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${submitDto.id_formulario} no encontrado`);
//1.1 Validar que no se haya rebasado el cupo máximo
if (
cuestionario.cupo_maximo !== null &&
cuestionario.cupo_maximo !== undefined
) {
const totalRespondidos =
await this.cuestionarioRespondidoRepository.count({
where: {
cuestionario: { id_cuestionario: submitDto.id_cuestionario },
},
});
if (totalRespondidos >= cuestionario.cupo_maximo) {
await queryRunner.rollbackTransaction();
return {
success: false,
message: `El cupo máximo de ${cuestionario.cupo_maximo} participantes ha sido alcanzado para el cuestionario ${cuestionario.nombre_form}.`,
};
}
}
// 2. Buscar o crear participante
let participante = await this.participanteRepository.findOne({
where: { correo: submitDto.correo }
where: { correo: submitDto.correo },
});
if (!participante) {
participante = new Participante();
participante.correo = submitDto.correo;
participante.id_tipo_user = 1; // Usar un tipo de usuario por defecto
participante = await queryRunner.manager.save(participante);
}
// Verificar si el participante ya ha respondido este cuestionario
const cuestionarioRespondidoExistente = await queryRunner.manager.findOne(
CuestionarioRespondido,
{
where: {
cuestionario: { id_cuestionario: submitDto.id_cuestionario },
participante: { id_participante: participante.id_participante },
},
relations: ['cuestionario', 'participante'],
},
);
if (cuestionarioRespondidoExistente) {
await queryRunner.rollbackTransaction();
let qrBuffer: Buffer | undefined;
let qrToken: string | undefined;
// Aunque ya haya respondido, enviamos el correo nuevamente
if (cuestionario && cuestionario.evento) {
try {
// Generar token para el QR
qrToken = this.qrTokenService.generateQrToken(
participante.id_participante,
cuestionario.evento.id_evento,
cuestionario.id_cuestionario,
cuestionario.fecha_fin
);
// Generar código QR con el token
qrBuffer = await QRCode.toBuffer(qrToken);
// Enviar correo con el QR
const urlApiCorreos = process.env.url_api_correos;
if (urlApiCorreos) {
await axios.post(
`${urlApiCorreos}/mail/send`,
{
to: participante.correo,
subject: `Evento: ${cuestionario.evento?.nombre_evento}`,
fecha_recibido: new Date().toISOString(),
html: generarHtmlCorreoAsistencia({
nombreForm: cuestionario.nombre_form,
nombreEvento: cuestionario.evento?.nombre_evento,
correo: participante.correo,
fechaRegistro: new Date().toLocaleString(),
fechaInicioEvento: cuestionario.evento?.fecha_inicio
? new Date(
cuestionario.evento.fecha_inicio,
).toLocaleString()
: undefined,
fechaFinEvento: cuestionario.evento?.fecha_fin
? new Date(cuestionario.evento.fecha_fin).toLocaleString()
: undefined,
}),
adjuntos: [
{
filename: 'qr.png',
content: qrBuffer.toString('base64'),
encoding: 'base64',
cid: 'qrCode',
},
],
},
{
headers: {
token: process.env.SYSTEM_TOKEN_API_CORREOS,
},
},
);
console.log(
`Correo reenviado exitosamente a ${participante.correo}`,
);
}
} catch (error) {
console.error('Error al reenviar correo:', error);
}
}
return {
success: true,
registrado: true,
message: `El participante con correo ${submitDto.correo} ya había respondido el cuestionario ${cuestionario.nombre_form}. Se ha reenviado el correo con el código QR.`,
qr_token: qrToken,
qr_buffer: qrBuffer ? qrBuffer.toString('base64') : undefined,
};
}
// 3. Crear un nuevo registro de cuestionario respondido
const cuestionarioRespondido = new CuestionarioRespondido();
cuestionarioRespondido.cuestionario = cuestionario;
cuestionarioRespondido.participante = participante;
cuestionarioRespondido.fechaRespuesta = submitDto.fecha_envio ?
new Date(submitDto.fecha_envio) : new Date();
const savedCuestionarioRespondido = await queryRunner.manager.save(cuestionarioRespondido);
cuestionarioRespondido.fechaRespuesta = submitDto.fecha_envio
? new Date(submitDto.fecha_envio)
: new Date();
const savedCuestionarioRespondido = await queryRunner.manager.save(
cuestionarioRespondido,
);
// 4. Procesar cada respuesta
for (const respuestaDto of submitDto.respuestas) {
// Buscar la pregunta
const pregunta = await this.preguntaRepository.findOne({
where: { id_pregunta: respuestaDto.id_pregunta },
relations: ['tipoPregunta']
relations: ['tipoPregunta'],
});
if (!pregunta) {
throw new NotFoundException(`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`);
throw new NotFoundException(
`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`,
);
}
// Si la pregunta tiene una validación configurada y es una respuesta abierta, validarla
if (pregunta.validacion && typeof respuestaDto.valor === 'string') {
const resultadoValidacion = this.validadorRespuestasService.validarRespuesta(
respuestaDto.valor,
pregunta.validacion
);
const resultadoValidacion =
this.validadorRespuestasService.validarRespuesta(
respuestaDto.valor,
pregunta.validacion,
);
if (!resultadoValidacion.valido) {
throw new BadRequestException(`Validación fallida en pregunta ${pregunta.id_pregunta}: ${resultadoValidacion.mensaje}`);
throw new BadRequestException(
`Validación fallida en pregunta ${pregunta.id_pregunta}: ${resultadoValidacion.mensaje}`,
);
}
}
// Determinar tipo de pregunta por ID
const esPreguntaAbierta = pregunta.id_tipo_pregunta === 2;
// O determinar tipo de pregunta por su nombre si existe
const esPreguntaTipoAbierta = pregunta.tipoPregunta &&
pregunta.tipoPregunta.tipo_pregunta === 'Abierta';
const esPreguntaTipoAbierta =
pregunta.tipoPregunta.tipo_pregunta ===
TipoPreguntaEnum.AbiertaParrafo ||
pregunta.tipoPregunta.tipo_pregunta ===
TipoPreguntaEnum.AbiertaRespuestaCorta;
// Determinar tipo de pregunta y guardar respuesta adecuadamente
if (esPreguntaAbierta || esPreguntaTipoAbierta) {
// Respuesta abierta (texto)
@@ -123,34 +244,38 @@ export class CuestionarioRespondidoService {
respuestaAbierta.pregunta = pregunta;
respuestaAbierta.respuesta = respuestaTexto;
respuestaAbierta.cuestionarioRespondido = savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaAbierta);
} else {
// Respuesta cerrada (opción)
// Verificar si es una respuesta múltiple (array de IDs)
const opcionIds = Array.isArray(respuestaDto.valor)
? respuestaDto.valor
: [respuestaDto.valor];
// Asegurarse de que opcionIds sea un array de números
const opcionIds = Array.isArray(respuestaDto.valor)
? respuestaDto.valor.map((v) => Number(v))
: [Number(respuestaDto.valor)];
// Procesar cada ID de opción
for (const opcionId of opcionIds) {
// Buscar la relación entre pregunta y opción usando el ID de opción
const preguntaOpciones = await this.preguntaOpcionRepository
.createQueryBuilder('po')
.innerJoinAndSelect('po.opcion', 'opcion')
.where('po.id_pregunta = :preguntaId', { preguntaId: pregunta.id_pregunta })
.andWhere('opcion.id_opcion = :opcionId', { opcionId: opcionId })
.getMany();
const preguntaOpciones = await this.preguntaOpcionRepository.find({
where: {
id_pregunta: pregunta.id_pregunta,
id_opcion: opcionId,
},
});
if (preguntaOpciones.length === 0) {
throw new BadRequestException(`La opción con ID ${opcionId} no está asociada a la pregunta ${respuestaDto.id_pregunta}`);
throw new BadRequestException(
`La opción con ID ${opcionId} no está asociada a la pregunta ${respuestaDto.id_pregunta}`,
);
}
// Guardar respuesta cerrada
const respuestaCerrada = new RespuestaParticipanteCerrada();
respuestaCerrada.preguntaOpcion = preguntaOpciones[0];
respuestaCerrada.cuestionarioRespondido = savedCuestionarioRespondido;
respuestaCerrada.cuestionarioRespondido =
savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaCerrada);
}
}
@@ -160,100 +285,133 @@ export class CuestionarioRespondidoService {
let datosQR: {
id_participante: number;
id_evento: number;
id_cuestionario: number;
} | null = null;
let qrBuffer: Buffer | undefined;
let qrToken: string | undefined;
// Capturamos los datos para el QR
if (cuestionario.evento && cuestionario.evento.id_evento) {
datosQR = {
id_participante: participante.id_participante,
id_evento: cuestionario.evento.id_evento
id_evento: cuestionario.evento.id_evento,
id_cuestionario: cuestionario.id_cuestionario,
};
}
// Primero completamos la transacción principal
await queryRunner.commitTransaction();
// Después de la transacción principal, intentamos registrar la relación participante-evento
// y enviar el correo como operaciones independientes
if (cuestionario.evento && cuestionario.evento.id_evento) {
if (cuestionario && cuestionario.evento) {
try {
// Registrar participante en evento
try {
// Verificar si ya existe una relación participante-evento
const participanteEventoExistente = await this.dataSource.query(
`SELECT * FROM participante_evento WHERE id_participante = ? AND id_evento = ?`,
[participante.id_participante, cuestionario.evento.id_evento]
`SELECT * FROM participante_evento WHERE id_participante = ? AND id_cuestionario = ?`,
[participante.id_participante, cuestionario.id_cuestionario],
);
// Si no existe, crear la relación
if (!participanteEventoExistente || participanteEventoExistente.length === 0) {
if (
!participanteEventoExistente ||
participanteEventoExistente.length === 0
) {
await this.dataSource.query(
`INSERT INTO participante_evento (id_participante, id_evento, fecha_inscripcion, estatus) VALUES (?, ?, ?, ?)`,
[participante.id_participante, cuestionario.evento.id_evento, new Date(), true]
`INSERT INTO participante_evento (id_participante, id_cuestionario, fecha_asistencia) VALUES (?, ?, ?)`,
[
participante.id_participante,
cuestionario.id_cuestionario,
null, // o puedes omitir esta columna y no incluirla en el query
],
);
}
} catch (dbError) {
console.error('Error al registrar participante en evento:', dbError.message);
console.error(
'Error al registrar participante en evento:',
dbError.message,
);
}
// Generar código QR
const qrJsonData = JSON.stringify(datosQR);
const qrBuffer = await QRCode.toBuffer(qrJsonData);
const qrDataUrl = await QRCode.toDataURL(qrJsonData);
console.log('QR Data URL:', qrDataUrl);
console.log("antes de enviar correo");
// Generar token para el QR
qrToken = this.qrTokenService.generateQrToken(
participante.id_participante,
cuestionario.evento.id_evento,
cuestionario.id_cuestionario,
cuestionario.fecha_fin
);
qrBuffer = await QRCode.toBuffer(qrToken);
// Enviar correo con el QR
const urlApiCorreos = process.env.url_api_correos || 'http://localhost:3000';
await axios.post(`${urlApiCorreos}/mail/send`, {
to: participante.correo,
subject: 'Asistencia QR',
fecha_recibido: "2025-03-10T12:00:00Z",
html: `
<h1>¡Gracias por registrarte!</h1>
<p>Has completado exitosamente el formulario: ${cuestionario.nombre_form}</p>
<p>Deberás presentar este QR (celular o impreso) en cualquier stand para que valide tu asistencia y
recibas tu constancia.</p>
<p>Este es tu QR para registrar asistencia:</p>
<img src="cid:qrCode" alt="Código QR" />
<p>Tus datos de registro:</p>
<ul>
<li>Evento: ${cuestionario.evento.nombre_evento || 'Evento'}</li>
<li>Correo: ${participante.correo}</li>
<li>Fecha de registro: ${new Date().toLocaleString()}</li>
</ul>
`,
adjuntos: [
{
filename: 'qr.png',
content: qrBuffer.toString('base64'),
encoding: 'base64',
cid: 'qrCode'
}
]
});
const urlApiCorreos = process.env.url_api_correos;
if (!urlApiCorreos) {
throw new BadRequestException(
'No se encontro servicio de correos configurado',
);
}
await axios.post(
`${urlApiCorreos}/mail/send`,
{
to: participante.correo,
subject: `Evento: ${cuestionario.evento?.nombre_evento}`,
fecha_recibido: '2025-03-10T12:00:00Z',
html: generarHtmlCorreoAsistencia({
nombreForm: cuestionario.nombre_form,
nombreEvento: cuestionario.evento?.nombre_evento,
correo: participante.correo,
fechaRegistro: new Date().toLocaleString(),
fechaInicioEvento: cuestionario.evento?.fecha_inicio
? new Date(cuestionario.evento.fecha_inicio).toLocaleString()
: undefined,
fechaFinEvento: cuestionario.evento?.fecha_fin
? new Date(cuestionario.evento.fecha_fin).toLocaleString()
: undefined,
}),
adjuntos: [
{
filename: 'qr.png',
content: qrBuffer.toString('base64'),
encoding: 'base64',
cid: 'qrCode',
},
],
},
{
headers: {
token: process.env.SYSTEM_TOKEN_API_CORREOS,
},
},
);
console.log(`Correo enviado exitosamente a ${participante.correo}`);
} catch (error) {
console.error('Error después de la transacción principal:', error);
// No lanzamos el error para que no afecte la respuesta al usuario
}
}
// Preparar respuesta
const response: any = {
success: true,
registrado: false,
message: 'Respuestas guardadas correctamente',
cuestionarioRespondidoId: savedCuestionarioRespondido.idCuestionarioRespondido
cuestionarioRespondidoId:
savedCuestionarioRespondido.idCuestionarioRespondido,
qr_token: qrToken,
qr_buffer: qrBuffer ? qrBuffer.toString('base64') : undefined,
};
// Incluir IDs para generar QR si hay evento asociado
if (datosQR) {
response.datos_qr = datosQR;
}
return response;
} catch (error) {
// Solo hacemos rollback si no hemos hecho commit todavía
@@ -270,15 +428,23 @@ recibas tu constancia.</p>
async findAll() {
// Obtener todos los cuestionarios respondidos con sus relaciones básicas
const cuestionariosRespondidos = await this.cuestionarioRespondidoRepository.find({
relations: ['participante', 'cuestionario', 'respuestasAbiertas', 'respuestasAbiertas.pregunta', 'respuestasCerradas']
});
const cuestionariosRespondidos =
await this.cuestionarioRespondidoRepository.find({
relations: [
'participante',
'cuestionario',
'respuestasAbiertas',
'respuestasAbiertas.pregunta',
'respuestasCerradas',
],
});
// Para cada cuestionario respondido, obtener y formatear las respuestas cerradas
const resultados = await Promise.all(
cuestionariosRespondidos.map(async (cuestionarioRespondido) => {
// Obtener datos de respuestas cerradas con consulta SQL
const respuestasCerradas = await this.dataSource.query(`
const respuestasCerradas = await this.dataSource.query(
`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
@@ -292,29 +458,34 @@ recibas tu constancia.</p>
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
WHERE rpc.id_cuestionario_respondido = ?
`, [cuestionarioRespondido.idCuestionarioRespondido]);
`,
[cuestionarioRespondido.idCuestionarioRespondido],
);
// Formatear respuestas cerradas
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion
}
}));
const respuestasCerradasFormateadas = respuestasCerradas.map(
(respuesta) => ({
idRespuestaParticipanteCerrada:
respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta,
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion,
},
}),
);
// Devolver cuestionario con respuestas formateadas
return {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas
respuestasCerradas: respuestasCerradasFormateadas,
};
})
}),
);
return resultados;
@@ -322,24 +493,28 @@ recibas tu constancia.</p>
async findOne(id: number) {
// Obtener cuestionario respondido con sus relaciones básicas
const cuestionarioRespondido = await this.cuestionarioRespondidoRepository.findOne({
where: { idCuestionarioRespondido: id },
relations: [
'participante',
'cuestionario',
'respuestasAbiertas',
'respuestasAbiertas.pregunta',
'respuestasCerradas'
]
});
const cuestionarioRespondido =
await this.cuestionarioRespondidoRepository.findOne({
where: { idCuestionarioRespondido: id },
relations: [
'participante',
'cuestionario',
'respuestasAbiertas',
'respuestasAbiertas.pregunta',
'respuestasCerradas',
],
});
if (!cuestionarioRespondido) {
throw new NotFoundException(`Cuestionario respondido con ID ${id} no encontrado`);
throw new NotFoundException(
`Cuestionario respondido con ID ${id} no encontrado`,
);
}
// Obtener directamente los datos de respuestas cerradas desde la base de datos
// usando una consulta SQL directa
const respuestasCerradas = await this.dataSource.query(`
const respuestasCerradas = await this.dataSource.query(
`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
@@ -353,35 +528,41 @@ recibas tu constancia.</p>
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
WHERE rpc.id_cuestionario_respondido = ?
`, [id]);
console.log('Respuestas cerradas SQL:', JSON.stringify(respuestasCerradas, null, 2));
`,
[id],
);
// Transformar las respuestas cerradas al formato deseado
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion
}
}));
const respuestasCerradasFormateadas = respuestasCerradas.map(
(respuesta) => ({
idRespuestaParticipanteCerrada:
respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta,
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion,
},
}),
);
// Crear el objeto de respuesta con el formato deseado
const resultado = {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas
respuestasCerradas: respuestasCerradasFormateadas,
};
return resultado;
}
update(id: number, updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto) {
update(
id: number,
updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto,
) {
return `This action updates a #${id} cuestionarioRespondido`;
}
@@ -389,6 +570,101 @@ recibas tu constancia.</p>
return `This action removes a #${id} cuestionarioRespondido`;
}
/* async generarReporteRespuestas(idCuestionario: number): Promise<string> {
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: idCuestionario },
relations: ['evento'],
});
if (!cuestionario) {
throw new NotFoundException(
`Cuestionario con ID ${idCuestionario} no encontrado`,
);
}
const preguntas = await this.dataSource.query(
`
SELECT
p.id_pregunta,
p.pregunta
FROM pregunta p
JOIN seccion_pregunta sp ON p.id_pregunta = sp.id_pregunta
JOIN seccion s ON sp.id_seccion = s.id_seccion
JOIN cuestionario_seccion cs ON s.id_seccion = cs.id_seccion
WHERE cs.id_cuestionario = ?
ORDER BY sp.posicion, p.id_pregunta
`,
[idCuestionario],
);
const respuestasAbiertas = await this.dataSource.query(
`
SELECT
cr.id_cuestionario_respondido,
p.id_pregunta,
rpa.respuesta
FROM respuesta_participante_abierta rpa
JOIN pregunta p ON rpa.id_pregunta = p.id_pregunta
JOIN cuestionario_respondido cr ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido
WHERE cr.id_cuestionario = ?
`,
[idCuestionario],
);
const respuestasCerradas = await this.dataSource.query(
`
SELECT
cr.id_cuestionario_respondido,
p.id_pregunta,
o.opcion
FROM respuesta_participante_cerrada rpc
JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
JOIN pregunta p ON po.id_pregunta = p.id_pregunta
JOIN opcion o ON po.id_opcion = o.id_opcion
JOIN cuestionario_respondido cr ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido
WHERE cr.id_cuestionario = ?
`,
[idCuestionario],
);
const mapaRespuestas = new Map<string, Map<number, string>>();
respuestasAbiertas.forEach((r) => {
if (!mapaRespuestas.has(r.id_cuestionario_respondido)) {
mapaRespuestas.set(r.id_cuestionario_respondido, new Map());
}
mapaRespuestas
.get(r.id_cuestionario_respondido)!
.set(r.id_pregunta, r.respuesta);
});
respuestasCerradas.forEach((r) => {
if (!mapaRespuestas.has(r.id_cuestionario_respondido)) {
mapaRespuestas.set(r.id_cuestionario_respondido, new Map());
}
const mapa = mapaRespuestas.get(r.id_cuestionario_respondido)!;
const anterior = mapa.get(r.id_pregunta) || '';
mapa.set(r.id_pregunta, anterior ? `${anterior}, ${r.opcion}` : r.opcion);
});
// Generar encabezado CSV
const encabezados = preguntas.map(
(p) => `"${p.pregunta.replace(/"/g, '""')}"`,
);
let csv = encabezados.join(',') + '\n';
// Generar filas por participante
for (const [, mapa] of mapaRespuestas) {
const fila = preguntas.map((p) => {
const valor = mapa.get(p.id_pregunta) || '';
return `"${valor.replace(/"/g, '""')}"`; // escapamos comillas dobles
});
csv += fila.join(',') + '\n';
}
return csv;
} */
async generarReporteRespuestas(idCuestionario: number): Promise<string> {
// Buscar el cuestionario para validar que existe
const cuestionario = await this.cuestionarioRepository.findOne({
@@ -397,11 +673,14 @@ recibas tu constancia.</p>
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${idCuestionario} no encontrado`);
throw new NotFoundException(
`Cuestionario con ID ${idCuestionario} no encontrado`,
);
}
// Obtener todas las preguntas del cuestionario para los encabezados
const preguntas = await this.dataSource.query(`
const preguntas = await this.dataSource.query(
`
SELECT
p.id_pregunta,
p.pregunta,
@@ -412,10 +691,13 @@ recibas tu constancia.</p>
JOIN cuestionario_seccion cs ON s.id_seccion = cs.id_seccion
WHERE cs.id_cuestionario = ?
ORDER BY sp.posicion, p.id_pregunta
`, [idCuestionario]);
`,
[idCuestionario],
);
// Obtener los participantes que han respondido este cuestionario
const participantes = await this.dataSource.query(`
const participantes = await this.dataSource.query(
`
SELECT DISTINCT
cr.id_cuestionario_respondido,
cr.fecha_respuesta,
@@ -424,13 +706,19 @@ recibas tu constancia.</p>
pe.fecha_asistencia
FROM cuestionario_respondido cr
JOIN participante p ON cr.id_participante = p.id_participante
LEFT JOIN participante_evento pe ON p.id_participante = pe.id_participante AND pe.id_evento = ?
LEFT JOIN participante_evento pe ON p.id_participante = pe.id_participante AND pe.id_cuestionario = ?
WHERE cr.id_cuestionario = ?
ORDER BY cr.fecha_respuesta DESC
`, [cuestionario.evento ? cuestionario.evento.id_evento : null, idCuestionario]);
`,
[
cuestionario.evento ? cuestionario.id_cuestionario : null,
idCuestionario,
],
);
// Obtener todas las respuestas para este cuestionario
const respuestasAbiertas = await this.dataSource.query(`
const respuestasAbiertas = await this.dataSource.query(
`
SELECT
cr.id_cuestionario_respondido,
p.id_pregunta,
@@ -439,9 +727,12 @@ recibas tu constancia.</p>
JOIN pregunta p ON rpa.id_pregunta = p.id_pregunta
JOIN cuestionario_respondido cr ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido
WHERE cr.id_cuestionario = ?
`, [idCuestionario]);
`,
[idCuestionario],
);
const respuestasCerradas = await this.dataSource.query(`
const respuestasCerradas = await this.dataSource.query(
`
SELECT
cr.id_cuestionario_respondido,
p.id_pregunta,
@@ -452,25 +743,30 @@ recibas tu constancia.</p>
JOIN opcion o ON po.id_opcion = o.id_opcion
JOIN cuestionario_respondido cr ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido
WHERE cr.id_cuestionario = ?
`, [idCuestionario]);
`,
[idCuestionario],
);
// Crear un mapa de respuestas por participante y pregunta
const respuestasPorParticipante = new Map();
// Agregar respuestas abiertas al mapa
respuestasAbiertas.forEach(respuesta => {
respuestasAbiertas.forEach((respuesta) => {
const key = `${respuesta.id_cuestionario_respondido}_${respuesta.id_pregunta}`;
respuestasPorParticipante.set(key, respuesta.respuesta);
});
// Agregar respuestas cerradas al mapa (posiblemente múltiples por pregunta)
respuestasCerradas.forEach(respuesta => {
respuestasCerradas.forEach((respuesta) => {
const key = `${respuesta.id_cuestionario_respondido}_${respuesta.id_pregunta}`;
const respuestaActual = respuestasPorParticipante.get(key);
if (respuestaActual) {
// Si ya hay una respuesta para esta pregunta, agregar esta opción
respuestasPorParticipante.set(key, `${respuestaActual}, ${respuesta.opcion}`);
respuestasPorParticipante.set(
key,
`${respuestaActual}, ${respuesta.opcion}`,
);
} else {
respuestasPorParticipante.set(key, respuesta.opcion);
}
@@ -478,54 +774,62 @@ recibas tu constancia.</p>
// Preparar los datos para el CSV
const datosReporte: string[][] = [];
// Agregar encabezados con datos del participante seguidos por las preguntas
const encabezados = [
'ID Participante',
'Correo',
'ID Participante',
'Correo',
'Fecha Respuesta',
'Asistencia'
'Asistencia',
];
// Agregar cada pregunta como un encabezado
preguntas.forEach(pregunta => {
preguntas.forEach((pregunta) => {
encabezados.push(pregunta.pregunta);
});
datosReporte.push(encabezados);
// Crear una fila por cada participante
participantes.forEach(participante => {
participantes.forEach((participante) => {
const fila = [
participante.id_participante.toString(),
participante.correo_participante,
new Date(participante.fecha_respuesta).toLocaleString(),
participante.fecha_asistencia ? 'Sí' : 'No'
participante.fecha_asistencia ? 'Sí' : 'No',
];
// Agregar cada respuesta en el orden de las preguntas
preguntas.forEach(pregunta => {
preguntas.forEach((pregunta) => {
const key = `${participante.id_cuestionario_respondido}_${pregunta.id_pregunta}`;
const respuesta = respuestasPorParticipante.get(key) || '';
fila.push(respuesta);
});
datosReporte.push(fila);
});
// Generar CSV de forma manual con valores separados por comas y líneas con saltos de línea
// Escapar valores que contengan comas para evitar problemas con el formato CSV
const csvContent = datosReporte.map(row =>
row.map(value => {
// Si el valor contiene comas, comillas o saltos de línea, encerrarlo en comillas dobles
// y escapar cualquier comilla doble dentro del valor
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
const csvContent = datosReporte
.map((row) =>
row
.map((value) => {
// Si el valor contiene comas, comillas o saltos de línea, encerrarlo en comillas dobles
// y escapar cualquier comilla doble dentro del valor
if (
value.includes(',') ||
value.includes('"') ||
value.includes('\n')
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
})
.join(','),
)
.join('\n');
return csvContent;
}
}
@@ -1,21 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
import {
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
ValidateIf,
MaxLength
MaxLength,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class RespuestaDto {
@ApiProperty({
description: 'ID de la pregunta respondida',
example: 101
example: 101,
})
@IsNumber()
id_pregunta: number;
@@ -23,40 +23,47 @@ export class RespuestaDto {
@ApiProperty({
description: 'Valor de la respuesta según tipo de pregunta',
examples: [
'Texto para pregunta abierta', // String para preguntas abiertas
5, // Número para preguntas cerradas (ID de opción)
[2, 7, 9] // Array para preguntas múltiples (IDs de opciones)
'Texto para pregunta abierta', // String para preguntas abiertas
5, // Número para preguntas cerradas (ID de opción)
[2, 7, 9], // Array para preguntas múltiples (IDs de opciones)
],
oneOf: [
{ type: 'string', description: 'Texto para pregunta abierta (máximo 250 caracteres)' },
{
type: 'string',
description: 'Texto para pregunta abierta (máximo 250 caracteres)',
},
{ type: 'number', description: 'ID de opción para pregunta cerrada' },
{
type: 'array',
{
type: 'array',
items: { type: 'number' },
description: 'Array de IDs de opciones para pregunta multiple'
}
]
description: 'Array de IDs de opciones para pregunta multiple',
},
],
})
@ValidateIf((o) => typeof o.valor === 'string')
@MaxLength(250, {
message: 'Las respuestas de texto no pueden exceder 250 caracteres',
})
@ValidateIf(o => typeof o.valor === 'string')
@MaxLength(250, { message: 'Las respuestas de texto no pueden exceder 250 caracteres' })
valor: string | number | number[];
}
export class SubmitRespuestasDto {
@ApiProperty({
description: 'ID del formulario a responder',
example: 1
description: 'ID del cuestionario a responder',
example: 1,
})
@IsNumber()
id_formulario: number;
id_cuestionario: number;
@ApiProperty({
description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com'
example: 'usuario@ejemplo.com',
})
@IsString()
@IsNotEmpty()
@MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' })
@MaxLength(250, {
message: 'El correo electrónico no puede exceder 250 caracteres',
})
correo: string;
@ApiProperty({
@@ -64,18 +71,18 @@ export class SubmitRespuestasDto {
type: [RespuestaDto],
examples: [
{
id_pregunta: 101,
valor: 'Texto para pregunta abierta'
id_pregunta: 101,
valor: 'Texto para pregunta abierta',
},
{
id_pregunta: 102,
valor: 5 // ID de la opción para pregunta cerrada simple
valor: 5,
},
{
id_pregunta: 103,
valor: [2, 7, 9] // Array de IDs de opciones para pregunta de selección múltiple
}
]
valor: [2, 7, 9],
},
],
})
@IsArray()
@ValidateNested({ each: true })
@@ -84,9 +91,9 @@ export class SubmitRespuestasDto {
@ApiProperty({
description: 'Fecha y hora del envío',
example: '2025-04-02T13:45:00'
example: '2025-04-02T13:45:00',
})
@IsDateString()
@IsOptional()
fecha_envio?: string;
}
}
@@ -3,7 +3,7 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColum
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { Participante } from 'src/participante/participante.entity';
import { Participante } from 'src/participante/entities/participante.entity';
import { Opcion } from 'src/opcion/entities/opcion.entity';
@Entity('cuestionario_respondido')
@@ -10,7 +10,6 @@ export class CuestionarioSeccion {
@Column({ type: 'tinyint' })
posicion: number;
@ManyToOne(() => Cuestionario, (cuestionario) => cuestionario.cuestionarioSeccion)
cuestionario: Cuestionario;
+80
View File
@@ -0,0 +1,80 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity';
import { RegistroTrabajador } from 'src/trabajadores/entities/trabajadore.entity';
import { Administrador } from 'src/administrador/entities/administrador.entity';
import { Asistencia } from 'src/asistencia/entities/asistencia.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity';
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { Opcion } from 'src/opcion/entities/opcion.entity';
import { Participante } from 'src/participante/entities/participante.entity';
import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity';
import { Pregunta } from 'src/pregunta/entities/pregunta.entity';
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { Seccion } from 'src/seccion/entities/seccion.entity';
import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity';
import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity';
import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
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,
],
retryAttempts: 5,
retryDelay: 3000,
connectTimeout: 30000,
synchronize: configService.get<string>('SYNCHRONIZE') === 'true',
dropSchema: configService.get<string>('DROP_SCHEMA') === '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: false,
dropSchema: false,
});
+1 -3
View File
@@ -18,11 +18,9 @@ export const swaggerConfig = new DocumentBuilder()
La API permite crear cuestionarios complejos con secciones y diferentes tipos de preguntas.
Consulte la documentación de cada endpoint para ver ejemplos específicos.
`)
.addServer(process.env.SWAGGER_SERVER_URL || 'http://localhost:3000')
.addServer( 'http://localhost:3000')
.setVersion('1.0')
.addTag('Administradores')
.addTag('Cuestionarios')
.addTag('Secciones')
.addTag('Preguntas')
+307
View File
@@ -0,0 +1,307 @@
export function generarHtmlCorreoAsistenciaTicket({
nombreForm,
nombreEvento,
correo,
fechaRegistro,
fechaInicioEvento,
fechaFinEvento,
}: {
nombreForm: string;
nombreEvento: string;
correo: string;
fechaRegistro: string;
fechaInicioEvento?: string;
fechaFinEvento?: string;
}) {
const horarioEvento =
fechaInicioEvento && fechaFinEvento
? `${fechaInicioEvento} al ${fechaFinEvento}`
: 'Por confirmar';
return `
<div
style="
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 650px;
margin: 0 auto;
padding: 20px;
border-radius: 16px;
"
>
<!-- Header del correo -->
<div style="text-align: center">
<h1>¡Registro Confirmado!</h1>
</div>
<!-- Ticket Container -->
<div
style="
background: white;
border-radius: 12px;
overflow: hidden;
background: #fefefe;
position: relative;
"
>
<!-- Ticket Header -->
<div
style="
background: linear-gradient(90deg, #003d79 0%, #0056b3 100%);
color: white;
padding: 20px;
position: relative;
"
>
<h2 style="margin: 0 0 8px 0; font-size: 24px; font-weight: bold">
🎓 ${nombreEvento || 'Evento Especial'}
</h2>
<p style="margin: 0; opacity: 0.9; font-size: 14px">
TICKET DE ENTRADA • ENTRADA GRATUITA
</p>
</div>
<!-- Ticket Body -->
<div style="padding: 30px 20px">
<div style="display: flex; gap: 30px; align-items: center">
<!-- QR Code Section -->
<div
style="
flex: 1;
display: flex;
justify-content: center;
align-items: center;
"
>
<div
style="
background: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 20px;
display: inline-block;
text-align: center;
"
>
<img
src="cid:qrCode"
alt="Código QR de Entrada"
style="
width: 180px;
height: 180px;
display: block;
margin: 0 auto;
"
/>
<p
style="
margin: 12px 0 0 0;
font-size: 12px;
color: #6c757d;
font-weight: 600;
"
>
ESCANEA PARA REGISTRAR ASISTENCIA
</p>
</div>
</div>
<!-- Ticket Info Section -->
<div style="flex: 1.2">
<div style="margin-bottom: 24px">
<h3
style="
color: #003d79;
margin: 0 0 16px 0;
font-size: 18px;
border-bottom: 2px solid #e9ecef;
padding-bottom: 8px;
"
>
📋 Detalles de tu registro
</h3>
<div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Participante
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${correo}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Formulario
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${nombreForm}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
Fecha de Registro
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #333;
font-weight: 500;
"
>
${fechaRegistro}
</p>
</div>
<div style="margin-bottom: 12px">
<p
style="
margin: 0;
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
font-weight: 600;
"
>
🕒 Horario del Evento
</p>
<p
style="
margin: 2px 0 0 0;
font-size: 15px;
color: #003d79;
font-weight: 600;
"
>
${horarioEvento}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ticket Footer -->
<div
style="
background: #f8f9fa;
padding: 16px 20px;
border-top: 2px dashed #dee2e6;
"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div>
<p style="margin: 0; font-size: 12px; color: #6c757d">
<strong>IMPORTANTE:</strong> Presenta este código QR en la mesa
de registro
</p>
<p style="margin: 4px 0 0 0; font-size: 11px; color: #6c757d">
Disponible en formato digital o impreso
</p>
</div>
<div style="text-align: right">
<p
style="
margin: 0;
font-size: 14px;
color: #003d79;
font-weight: bold;
"
>
ENTRADA VÁLIDA
</p>
<p style="margin: 2px 0 0 0; font-size: 11px; color: #28a745">
✓ Verificado
</p>
</div>
</div>
</div>
</div>
<!-- Instrucciones adicionales -->
<div
style="
margin-top: 20px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 16px;
"
>
<h4 style="color: #003d79; margin: 0 0 12px 0; font-size: 16px">
📝 Instrucciones importantes:
</h4>
<ul
style="
margin: 0;
padding-left: 20px;
color: #333;
font-size: 14px;
line-height: 1.6;
"
>
<li>Llega 15 minutos antes del inicio del evento</li>
<li>
Presenta este QR en la mesa de registro para validar tu asistencia
</li>
<li>Tu constancia será otorgada al finalizar el evento</li>
<li>Para dudas, acude al módulo de información durante el evento</li>
</ul>
</div>
<!-- Footer del correo -->
<div style="text-align: center; margin-top: 20px">
<p style="color: rgba(255, 255, 255, 0.8); font-size: 13px; margin: 0">
Este mensaje fue enviado automáticamente. Por favor, no respondas a
este correo.
</p>
</div>
</div>
`;
}
+62
View File
@@ -0,0 +1,62 @@
export function generarHtmlCorreoAsistencia({
nombreForm,
nombreEvento,
correo,
fechaRegistro,
fechaInicioEvento,
fechaFinEvento,
}: {
nombreForm: string;
nombreEvento: string;
correo: string;
fechaRegistro: string;
fechaInicioEvento?: string;
fechaFinEvento?: 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;">
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.
</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>
`;
}
+68
View File
@@ -0,0 +1,68 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBody,
ApiParam,
ApiConsumes,
} from '@nestjs/swagger';
import { CreateEventoDto } from '../dto/create-evento.dto';
import { UpdateEventoDto } from '../dto/update.evento.dto';
import {
actualizacionEventoNombreYFechas,
actualizacionEventoTipo,
ejemploEventoAcademico,
ejemploEventoCultural,
ejemploFeriaSexualidad,
} from './evento.examples';
export class EventoApiDocumentation {
static ApiController = ApiTags('Evento');
static ApiCreate = applyDecorators(
ApiOperation({ summary: 'Crear un nuevo evento' }),
ApiBody({
type: CreateEventoDto,
examples: {
ejemplo1: ejemploFeriaSexualidad,
ejemplo2: ejemploEventoAcademico,
ejemplo3: ejemploEventoCultural,
},
}),
);
static ApiUpdate = applyDecorators(
ApiOperation({ summary: 'Actualizar un evento existente por ID' }),
ApiBody({
type: UpdateEventoDto,
examples: {
actualizacion1: actualizacionEventoNombreYFechas,
actualizacion2: actualizacionEventoTipo,
},
}),
);
static ApiPostBanner = applyDecorators(
ApiOperation({ summary: 'Subir banner para un evento' }),
ApiParam({
name: 'id',
type: Number,
description: 'ID del evento al que se asigna el banner',
}),
ApiConsumes('multipart/form-data'),
ApiBody({
schema: {
type: 'object',
properties: {
banner: {
type: 'string',
format: 'binary',
description:
'Archivo de imagen (jpg, png, webp) que será el banner del evento',
},
},
required: ['banner'],
},
}),
);
}
+57
View File
@@ -0,0 +1,57 @@
/* CREATE */
export const ejemploFeriaSexualidad = {
summary: 'Evento que simula una feria',
description:
'Un evento de una feria de la sexualidad, con registro para la comunidad universitaria',
value: {
tipo_evento: 'Estudiantil',
nombre_evento: 'Feria de la Sexualidad 2026-3',
descripcion_evento:
'**La Feria de la Sexualidad** es un espacio seguro e informativo dirigido a toda la comunidad universitaria. Este registro nos ayudará a organizar mejor las actividades, talleres y charlas, así como conocer tus intereses. Por favor, completa el siguiente formulario para confirmar tu participación.',
fecha_inicio: '2025-07-10T09:00:00.000Z',
fecha_fin: '2025-07-11T17:00:00.000Z',
},
};
export const ejemploEventoAcademico = {
summary: 'Evento académico',
description: 'Un evento tipo seminario académico de dos días',
value: {
tipo_evento: 'Académico',
nombre_evento: 'Seminario de Innovación Educativa',
fecha_inicio: '2025-07-10T09:00:00.000Z',
fecha_fin: '2025-07-11T17:00:00.000Z',
},
};
export const ejemploEventoCultural = {
summary: 'Evento cultural',
description: 'Un evento cultural de un solo día',
value: {
tipo_evento: 'Cultural',
nombre_evento: 'Festival de Música y Arte',
fecha_inicio: '2025-08-05T15:00:00.000Z',
fecha_fin: '2025-08-05T22:00:00.000Z',
},
};
/* UPDATE */
export const actualizacionEventoNombreYFechas = {
summary: 'Actualizar nombre y fechas',
description: 'Se cambia el nombre del evento y se ajustan las fechas',
value: {
nombre_evento: 'Seminario de Tecnología Educativa',
fecha_inicio: '2025-07-15T10:00:00.000Z',
fecha_fin: '2025-07-16T18:00:00.000Z',
},
};
export const actualizacionEventoTipo = {
summary: 'Solo cambiar tipo de evento',
description: 'Se actualiza el tipo sin modificar lo demás',
value: {
tipo_evento: 'Deportivo',
},
};
+25 -6
View File
@@ -1,11 +1,30 @@
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateEventoDto {
tipo_evento: string;
@IsString()
@IsNotEmpty({ message: 'El tipo de evento es obligatorio.' })
tipo_evento: string;
nombre_evento: string;
@IsString()
@IsNotEmpty({ message: 'El nombre del evento es obligatorio.' })
nombre_evento: string;
fecha_inicio: Date;
@IsOptional()
@IsNotEmpty({ message: 'La descripción del evento es obligatoria.' })
descripcion_evento: string;
fecha_fin: Date;
@Type(() => Date)
@IsDate({ message: 'La fecha de inicio debe ser una fecha válida.' })
@IsNotEmpty({ message: 'La fecha de inicio es obligatoria.' })
fecha_inicio: Date;
//agregar el id del administrador
}
@Type(() => Date)
@IsDate({ message: 'La fecha de fin debe ser una fecha válida.' })
@IsNotEmpty({ message: 'La fecha de fin es obligatoria.' })
fecha_fin: Date;
@IsOptional()
@IsString()
banner?: string;
}
+73
View File
@@ -0,0 +1,73 @@
import { Expose, Type } from 'class-transformer';
import { TipoCuestionarioOutputDto } from 'src/tipo_cuestionario/dto/outputs.dto';
import { TipoEventoOutputDto } from 'src/tipo_evento/dto/outputs.dto';
export class CuestionarioConCupoDto {
@Expose()
id_cuestionario: number;
@Expose()
nombre_form: string;
@Expose()
descripcion?: string;
@Expose()
banner: string;
@Expose()
fecha_inicio: Date;
@Expose()
fecha_fin: Date;
@Expose()
cupo_maximo: number | null;
@Expose()
cupos_usados: number;
@Expose()
cupos_disponibles: number | null;
@Expose()
id_evento: number;
@Expose()
@Type(() => TipoCuestionarioOutputDto)
tipoCuestionario: TipoCuestionarioOutputDto;
@Expose()
@Type(() => TipoEventoOutputDto)
tipoEvento: TipoEventoOutputDto;
}
export class EventoConCuestionariosDto {
@Expose()
id_evento: number;
@Expose()
tipo_evento: string;
@Expose()
nombre_evento: string;
@Expose()
descripcion_evento: string;
@Expose()
fecha_inicio: Date;
@Expose()
fecha_fin: Date;
@Expose()
asistencias: any;
@Expose()
banner: string | null;
@Expose()
@Type(() => CuestionarioConCupoDto) // <- Esto es lo que faltaba
cuestionarios: CuestionarioConCupoDto[];
}
+26 -5
View File
@@ -1,6 +1,27 @@
import { IsOptional, IsString, IsNotEmpty, IsDate } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateEventoDto {
tipo_evento?: string
nombre_evento: string
fecha_inicio?: Date
fecha_fin?: Date
}
@IsOptional()
@IsString()
@IsNotEmpty({ message: 'El tipo de evento no puede estar vacío si se proporciona' })
tipo_evento?: string;
@IsString()
@IsOptional()
nombre_evento: string;
@IsString()
@IsOptional()
descripcion_evento: string;
@IsOptional()
@Type(() => Date)
@IsDate({ message: 'La fecha de inicio debe ser una fecha válida' })
fecha_inicio?: Date;
@IsOptional()
@Type(() => Date)
@IsDate({ message: 'La fecha de fin debe ser una fecha válida' })
fecha_fin?: Date;
}
+46
View File
@@ -0,0 +1,46 @@
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class Evento {
@PrimaryGeneratedColumn()
id_evento: number;
@Column({ length: 50 })
tipo_evento: string;
@Column({ length: 200 })
nombre_evento: string;
@Column({ length: 500, nullable: true })
descripcion_evento: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_inicio: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_fin: Date;
@Column({ nullable: true })
asistencias: number;
@Column({ nullable: true })
banner: string;
@OneToMany(() => Cuestionario, (cuestionario) => cuestionario.evento)
cuestionarios: Cuestionario[];
@ManyToOne(() => TipoEvento, (tipo) => tipo.eventos, {
eager: true,
})
@JoinColumn({ name: 'id_tipo_evento' })
tipoEvento: TipoEvento;
}
+109 -24
View File
@@ -1,37 +1,122 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UnprocessableEntityException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { EventoService } from './evento.service';
import { Evento } from './evento.entity';
import { Evento } from './entities/evento.entity';
import { CreateEventoDto } from './dto/create-evento.dto';
import { UpdateEventoDto } from './dto/update.evento.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { EventoApiDocumentation } from './docs/evento.documentation';
@Controller('evento')
@EventoApiDocumentation.ApiController
export class EventoController {
constructor(private eventoService: EventoService) {}
constructor(private eventoService: EventoService) {}
@Get()
getEventos(): Promise<Evento[]> {
return this.eventoService.getEventos()
@Post()
@EventoApiDocumentation.ApiCreate
createEvento(@Body() newEvento: CreateEventoDto) {
return this.eventoService.createEvento(newEvento);
}
@Get()
getEventos(): Promise<Evento[]> {
return this.eventoService.getEventos();
}
@Get('activos/cuestionarios')
getCuestionariosActivos() {
return this.eventoService.getEventosActivosCuestionarios();
}
@Get('recientes/cuestionarios')
getEventosRecientes() {
return this.eventoService.getEventosRecientesCuestionarios();
}
@Get('activos')
getEventosActivos(): Promise<Evento[]> {
return this.eventoService.getEventosActivos();
}
@Get(':id/cuestionarios')
getCuestionarios(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.getCuestionariosEvento(id);
}
@Get(':id_evento/cuestionario/:id_cuestionario')
getCuestionarioEvento(
@Param('id_evento', ParseIntPipe) id_evento: number,
@Param('id_cuestionario', ParseIntPipe) id_cuestionario: number,
) {
return this.eventoService.getCuestionarioEvento(id_evento, id_cuestionario);
}
@Post(':id/banner')
@UseInterceptors(
FileInterceptor('banner', {
storage: diskStorage({
destination: './uploads/banners', // Asegúrate de que esta carpeta exista
filename: (req, file, cb) => {
const uniqueSuffix =
Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `banner-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const ext = extname(file.originalname).toLowerCase();
if (allowedTypes.test(ext)) {
cb(null, true);
} else {
cb(new Error('Tipo de archivo no permitido'), false);
}
},
}),
)
@EventoApiDocumentation.ApiPostBanner
async subirBannerEvento(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new UnprocessableEntityException(
'No se ha subido ningún archivo o el archivo no es válido.',
);
}
@Get(':id')
getEvento(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.getEvento(id)
}
return this.eventoService.asociarBanner(id, file.filename);
}
@Post()
createEvento(@Body() newEvento: CreateEventoDto) {
return this.eventoService.createEvento(newEvento)
}
@Get(':id')
getEvento(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.getEvento(id);
}
@Delete(':id')
deleteEvento(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.deleteEvento(id)
}
//@Param(':id', ParseIntPipe) id: number, @Body() tipoUser: UpdateTipoUserDto
@Patch()
updateEvento(@Param(':id', ParseIntPipe) id: number, @Body() evento: UpdateEventoDto) {
return this.eventoService.updateEvento(id, evento)
}
@Delete(':id')
deleteEvento(@Param('id', ParseIntPipe) id: number) {
return this.eventoService.deleteEvento(id);
}
@Patch(':id')
@EventoApiDocumentation.ApiUpdate
updateEvento(
@Param('id', ParseIntPipe) id: number,
@Body() evento: UpdateEventoDto,
) {
return this.eventoService.updateEvento(id, evento);
}
}
-41
View File
@@ -1,41 +0,0 @@
import { Participante } from "src/participante/participante.entity";
import { ParticipanteEvento } from "src/participante_evento/participante_evento.entity";
import { Column, Entity, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Evento {
@PrimaryGeneratedColumn()
id_evento: number
@Column()
tipo_evento: string
@Column()
nombre_evento: string
@Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_inicio: Date
@Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_fin: Date
/* falta hacer la relacion
@Column()
id_administrador: number
@ManyToOne(() => Administrador, (admin) => admin.eventos)
@JoinColumn({ name: "id_administrador" })
administrador: Administrador;
@OneToMany(() => Asistencia, (asistencia) => asistencia.evento)
asistencias: Asistencia[];
*/
@OneToMany(() => ParticipanteEvento, participanteEvento => participanteEvento.evento)
participanteEventos: ParticipanteEvento[];
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { EventoService } from './evento.service';
import { EventoController } from './evento.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Evento } from './evento.entity';
import { Evento } from './entities/evento.entity';
@Module({
imports: [TypeOrmModule.forFeature([Evento])],
+253 -53
View File
@@ -1,73 +1,273 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Evento } from './evento.entity';
import { MoreThan, Repository } from 'typeorm';
import { Evento } from './entities/evento.entity';
import { CreateEventoDto } from './dto/create-evento.dto';
import { UpdateEventoDto } from './dto/update.evento.dto';
import { join } from 'path';
import { existsSync, unlinkSync } from 'fs';
import { plainToInstance } from 'class-transformer';
import { EventoConCuestionariosDto } from './dto/outpus.dto';
@Injectable()
export class EventoService {
constructor(
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
) {}
constructor(
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
) {}
async createEvento(evento: CreateEventoDto) {
const eventoFound = await this.eventoRepository.findOne({
where: {
nombre_evento: evento.nombre_evento,
tipo_evento: evento.tipo_evento
}
})
async createEvento(evento: CreateEventoDto) {
const eventoFound = await this.eventoRepository.findOne({
where: {
nombre_evento: evento.nombre_evento,
tipo_evento: evento.tipo_evento,
},
});
if (eventoFound)
return new HttpException('Evento already exists', HttpStatus.CONFLICT)
const createEvento = this.eventoRepository.create(evento)
if (eventoFound)
throw new HttpException(
'Ya existe un evento con el mismo nombre y tipo, puede agregar el periodo al nombre del evento para diferenciarlos',
HttpStatus.BAD_REQUEST,
);
return this.eventoRepository.save(createEvento)
}
const createEvento = this.eventoRepository.create(evento);
getEventos() {
return this.eventoRepository.find({
relations: ['participantes']
})
}
return this.eventoRepository.save(createEvento);
}
async getEvento(id_evento: number) {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento
},
relations: ['participantes']
})
// evento.service.ts
if (!eventoFound)
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
async asociarBanner(id: number, filename: string) {
const evento = await this.getEventoOrFail(id);
return eventoFound
}
async deleteEvento(id_evento: number) {
const result = await this.eventoRepository.delete({ id_evento })
if (result.affected === 0) {
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
// Si ya tiene un banner anterior, eliminarlo
if (evento.banner) {
const bannerPath = join(
__dirname,
'..',
'..',
'uploads',
'banners',
evento.banner,
);
if (existsSync(bannerPath)) {
try {
unlinkSync(bannerPath);
} catch (err: unknown) {
let errorMessage = '';
if (err && typeof err === 'object' && 'message' in err) {
errorMessage = (err as { message?: string }).message ?? '';
}
console.error(
`Error al eliminar el banner anterior: ${errorMessage}`,
);
}
return result
}
}
async updateEvento(id_evento: number, evento: UpdateEventoDto) {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento
}
})
// Asociar el nuevo banner
evento.banner = filename;
return this.eventoRepository.save(evento);
}
if (!eventoFound)
return new HttpException('Evento not found', HttpStatus.NOT_FOUND)
getEventos() {
return this.eventoRepository.find();
}
const updateEvento = Object.assign(eventoFound, evento)
return this.eventoRepository.save(updateEvento)
async getCuestionariosEvento(id_evento: number) {
const eventos = await this.eventoRepository.findOne({
where: {
id_evento: id_evento,
},
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
});
if (!eventos) {
throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`);
}
const eventoConCupos = {
...eventos,
cuestionarios: eventos.cuestionarios.map((cuest) => {
const cupoMaximo = cuest.cupo_maximo ?? null;
const usados = cuest.cuestionariosRespondidos?.length || 0;
const disponibles =
cupoMaximo !== null ? Math.max(0, cupoMaximo - usados) : null;
return {
...cuest,
cupos_usados: usados,
cupos_disponibles: disponibles,
};
}),
};
return plainToInstance(EventoConCuestionariosDto, eventoConCupos, {
excludeExtraneousValues: true,
});
}
async getEvento(id_evento: number) {
return await this.eventoRepository.findOne({
where: {
id_evento,
},
});
}
async getEventosActivosCuestionarios() {
const eventos = await this.eventoRepository.find({
where: {
fecha_fin: MoreThan(new Date()),
},
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
});
const eventosFiltrados = eventos.filter(
(evento) => !(evento.id_evento >= 10 && evento.id_evento <= 148),
);
const eventosConCupos = eventosFiltrados.map((evento) => {
return {
...evento,
cuestionarios: evento.cuestionarios.map((cuest) => {
const cupoMaximo = cuest.cupo_maximo ?? null;
const usados = cuest.cuestionariosRespondidos?.length || 0;
const disponibles =
cupoMaximo !== null ? Math.max(0, cupoMaximo - usados) : null;
return {
...cuest,
cupos_usados: usados,
cupos_disponibles: disponibles,
};
}),
};
});
return plainToInstance(EventoConCuestionariosDto, eventosConCupos, {
excludeExtraneousValues: true,
});
}
async getEventoOrFail(id_evento: number): Promise<Evento> {
const eventoFound = await this.eventoRepository.findOne({
where: {
id_evento,
},
});
if (!eventoFound) {
throw new HttpException(
'El evento buscado no existe',
HttpStatus.NOT_FOUND,
);
}
return eventoFound;
}
async deleteEvento(id_evento: number) {
await this.getEventoOrFail(id_evento);
const result = await this.eventoRepository.delete(id_evento);
if (result.affected === 0) {
throw new HttpException('Evento not found', HttpStatus.NOT_FOUND);
}
return { message: 'Evento eliminado exitosamente' };
}
async updateEvento(id_evento: number, evento: UpdateEventoDto) {
const eventoFound = await this.getEventoOrFail(id_evento);
const updateEvento = Object.assign(eventoFound, evento);
return this.eventoRepository.save(updateEvento);
}
async getEventosRecientesCuestionarios(): Promise<
EventoConCuestionariosDto[]
> {
const ahora = new Date();
const hace30Dias = new Date();
hace30Dias.setDate(ahora.getDate() - 30);
const eventos = await this.eventoRepository.find({
where: {
fecha_fin: MoreThan(hace30Dias),
},
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
});
const eventosConCupos = eventos.map((evento) => {
return {
...evento,
cuestionarios: evento.cuestionarios.map((cuest) => {
const cupoMaximo = cuest.cupo_maximo ?? null;
const usados = cuest.cuestionariosRespondidos?.length || 0;
const disponibles =
cupoMaximo !== null ? Math.max(0, cupoMaximo - usados) : null;
return {
...cuest,
cupos_usados: usados,
cupos_disponibles: disponibles,
};
}),
};
});
return plainToInstance(EventoConCuestionariosDto, eventosConCupos, {
excludeExtraneousValues: true,
});
}
async getEventosActivos() {
const now = new Date();
const eventos = await this.eventoRepository.find({
where: {
fecha_fin: MoreThan(now),
},
relations: ['cuestionarios'],
});
return eventos.map((evento) => ({
...evento,
total_cuestionarios: evento.cuestionarios
? evento.cuestionarios.length
: 0,
}));
}
async getCuestionarioEvento(id_evento: number, id_cuestionario: number) {
const evento = await this.eventoRepository.findOne({
where: { id_evento },
relations: ['cuestionarios'],
});
if (!evento) {
throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`);
}
const cuestionario = evento.cuestionarios.find(
(c) => c.id_cuestionario === id_cuestionario,
);
if (!cuestionario) {
throw new NotFoundException(
`Cuestionario no asignado al evento ${evento.nombre_evento}.`,
);
}
// Return evento info, but only with the searched cuestionario
return {
...evento,
cuestionarios: undefined,
cuestionario: cuestionario,
};
}
}
+35 -34
View File
@@ -1,23 +1,28 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import 'dotenv/config';
import { DocsModule } from './docs/docs.module';
import { DataSource } from 'typeorm';
import { TipoPregunta } from './tipo_pregunta/entities/tipo_pregunta.entity';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
// Configuración de validación global
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Elimina propiedades no decoradas
forbidNonWhitelisted: true, // Arroja error si hay propiedades no decoradas
transform: true, // Transforma los datos recibidos al tipo definido en el DTO
})
}),
);
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
index: false,
prefix: '/',
});
// CORS configuration
app.enableCors({
origin: '*',
@@ -25,34 +30,30 @@ async function bootstrap() {
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Swagger setup
DocsModule.setupSwagger(app);
const config = new DocumentBuilder()
.setTitle('Formularios API')
.setDescription(
`Esta es la API del sistema de registros para eventos de la FES Acatlán. Permite gestionar eventos académicos y sus formularios asociados.
// Seed TipoPregunta data
const dataSource = app.get(DataSource);
const tiposPreguntaRepository = dataSource.getRepository(TipoPregunta);
// Check if data exists
const count = await tiposPreguntaRepository.count();
if (count === 0) {
// Create default tipos de pregunta
const tiposPregunta = [
{ tipo_pregunta: 'Texto' },
{ tipo_pregunta: 'Numero' },
{ tipo_pregunta: 'Radio' },
{ tipo_pregunta: 'Multiple' },
{ tipo_pregunta: 'Abierto' },
{ tipo_pregunta: 'Fecha' },
];
await tiposPreguntaRepository.save(tiposPregunta);
console.log('Tipos de pregunta creados');
}
Flujo de creación:
1. Primero, se debe crear un evento usando **POST /evento**.
2. Luego, se crea un formulario y se relaciona con el evento usando **POST /cuestionario**.
3. (Opcional) También se puede crear un formulario y un evento al mismo tiempo usando **POST /cuestionario/evento**, lo cual genera automáticamente ambos y los relaciona.`,
)
.setVersion('1.0')
.addBearerAuth()
.addTag('Evento')
.addTag('Cuestionario')
.addTag('Cuestionario Respondido')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// Start the server
await app.listen(process.env.PORT ?? 4200);
console.log('API en ejecución en http://localhost:4200');
await app.listen(configService.get<number>('API_PORT') || 4200);
console.log('\nAPI en ejecución en http://localhost:4200');
console.log('Swagger disponible en http://localhost:4200/api-docs');
}
bootstrap();
+3 -5
View File
@@ -1,5 +1,5 @@
import { PreguntaOpcion } from "src/pregunta_opcion/entities/pregunta_opcion.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity';
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Opcion {
@@ -11,6 +11,4 @@ export class Opcion {
@OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.opcion)
preguntasOpciones: PreguntaOpcion[];
}
}
@@ -10,13 +10,4 @@ export class CreateParticipanteDto {
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
correo: string;
@ApiProperty({
description: 'ID del tipo de usuario',
example: 1,
required: true
})
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
@IsNotEmpty({ message: 'El ID del tipo de usuario es requerido' })
id_tipo_user: number;
}
@@ -0,0 +1,31 @@
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity';
import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity';
import {
Column,
Entity,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class Participante {
@PrimaryGeneratedColumn()
id_participante: number;
@Column({ unique: true })
correo: string;
// Cuestionarios que ha respondido
@OneToMany(
() => CuestionarioRespondido,
(cuestionarioRespondido) => cuestionarioRespondido.participante,
)
cuestionariosRespondidos: CuestionarioRespondido[];
// Cuestionarios a los que se ha inscrito (subeventos)
@OneToMany(
() => ParticipanteEvento,
(participanteEvento) => participanteEvento.participante,
)
participanteEventos: ParticipanteEvento[];
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { Participante } from './participante.entity';
import { Participante } from './entities/participante.entity';
import { ParticipanteService } from './participante.service';
import { CreateParticipanteDto } from './dto/create-participante.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto';
-49
View File
@@ -1,49 +0,0 @@
import { CuestionarioRespondido } from "src/cuestionario_respondido/entities/cuestionario_respondido.entity";
import { Evento } from "src/evento/evento.entity";
import { ParticipanteEvento } from "src/participante_evento/participante_evento.entity";
import { TipoUser } from "src/tipo_user/tipo_user.entity";
import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Participante {
@PrimaryGeneratedColumn()
id_participante: number
@Column()
correo: string
@Column()
id_tipo_user: number
//Relacion con tipo usuario
@ManyToOne(() => TipoUser, tipoUser => tipoUser.participante)
@JoinColumn({ name: 'participante_id' }) //nombre de la relacion
tipo_user: TipoUser
/*
@ManyToOne(() => Administrador, (admin) => admin.eventos)
@JoinColumn({ name: "id_administrador" })
administrador: Administrador;
@ManyToMany(() => Evento, evento => evento.participantes)
@JoinTable({
name: "participante_evento",
joinColumn: { name: "id_participante", referencedColumnName: "id_participante" },
inverseJoinColumn: { name: "id_evento", referencedColumnName: "id_evento" }
})
eventos: Evento[];
@OneToMany(() => Asistencia, (asistencia) => asistencia.evento)
asistencias: Asistencia[];
*/
@OneToMany(() => CuestionarioRespondido, cuestionario => cuestionario.participante)
cuestionariosRespondidos: CuestionarioRespondido[];
@OneToMany(() => ParticipanteEvento, participanteEvento => participanteEvento.participante)
participanteEventos: ParticipanteEvento[];
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ParticipanteService } from './participante.service';
import { ParticipanteController } from './participante.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Participante } from './participante.entity';
import { Participante } from './entities/participante.entity';
@Module({
imports: [TypeOrmModule.forFeature([Participante])],
+156 -100
View File
@@ -1,124 +1,180 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Participante } from './participante.entity';
import { Participante } from './entities/participante.entity';
import { Repository } from 'typeorm';
import { CreateParticipanteDto } from './dto/create-participante.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto';
@Injectable()
export class ParticipanteService {
constructor(
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
) {}
constructor(
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
) {}
/**
* Crea un nuevo participante
* @param participante Datos del participante a crear
* @returns El participante creado
* @throws ConflictException si ya existe un participante con el mismo correo
*/
async createParticipante(participante: CreateParticipanteDto): Promise<Participante> {
// Verificar si ya existe un participante con el mismo correo
const participanteFound = await this.participanteRepository.findOne({
where: {
correo: participante.correo
}
});
/**
* Crea un nuevo participante
* @param participante Datos del participante a crear
* @returns El participante creado
* @throws ConflictException si ya existe un participante con el mismo correo
*/
async createParticipante(
participante: CreateParticipanteDto,
): Promise<Participante> {
// Verificar si ya existe un participante con el mismo correo
const participanteFound = await this.participanteRepository.findOne({
where: {
correo: participante.correo,
},
});
if (participanteFound) {
throw new ConflictException(`Ya existe un participante con el correo ${participante.correo}`);
}
const nuevoParticipante = this.participanteRepository.create(participante);
return this.participanteRepository.save(nuevoParticipante);
if (participanteFound) {
throw new ConflictException(
`Ya existe un participante con el correo ${participante.correo}`,
);
}
/**
* Obtiene todos los participantes
* @returns Lista de participantes con sus relaciones
*/
async getParticipantes(): Promise<Participante[]> {
return this.participanteRepository.find({
relations: ['tipo_user', 'participanteEventos']
});
const nuevoParticipante = this.participanteRepository.create(participante);
return this.participanteRepository.save(nuevoParticipante);
}
/**
* Obtiene todos los participantes
* @returns Lista de participantes con sus relaciones
*/
async getParticipantes(): Promise<Participante[]> {
return this.participanteRepository.find({
relations: ['tipo_user', 'participanteEventos'],
});
}
/**
* Obtiene un participante por su ID
* @param id_participante ID del participante a buscar
* @returns El participante encontrado con sus relaciones
* @throws NotFoundException si no se encuentra el participante
*/
async getParticipante(id_participante: number): Promise<Participante> {
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante,
},
relations: ['tipo_user', 'participanteEventos'],
});
if (!participanteFound) {
throw new NotFoundException(
`Participante con ID ${id_participante} no encontrado`,
);
}
/**
* 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']
});
return participanteFound;
}
if (!participanteFound) {
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
return participanteFound;
async getParticipanteByCorreo(correo: string): Promise<Participante | null> {
const participanteFound = await this.participanteRepository.findOne({
where: {
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
return participanteFound;
}
async getParticipanteByCorreoOrFail(correo: string): Promise<Participante> {
const participanteFound = await this.participanteRepository.findOne({
where: {
correo,
},
relations: ['tipo_user', 'participanteEventos'],
});
if (!participanteFound) {
throw new NotFoundException(
`Participante con correo ${correo} no encontrado`,
);
}
/**
* Elimina un participante por su ID
* @param id_participante ID del participante a eliminar
* @returns Resultado de la eliminación
* @throws NotFoundException si no se encuentra el participante
*/
async deleteParticipante(id_participante: number) {
const result = await this.participanteRepository.delete({ id_participante });
return participanteFound;
}
if (result.affected === 0) {
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
/**
* Elimina un participante por su ID
* @param id_participante ID del participante a eliminar
* @returns Resultado de la eliminación
* @throws NotFoundException si no se encuentra el participante
*/
async deleteParticipante(id_participante: number) {
const result = await this.participanteRepository.delete({
id_participante,
});
return {
success: true,
message: `Participante con ID ${id_participante} eliminado correctamente`,
affected: result.affected
};
if (result.affected === 0) {
throw new NotFoundException(
`Participante con ID ${id_participante} no encontrado`,
);
}
/**
* Actualiza los datos de un participante
* @param id_participante ID del participante a actualizar
* @param participante Datos actualizados del participante
* @returns El participante actualizado
* @throws NotFoundException si no se encuentra el participante
* @throws BadRequestException si los datos son inválidos
*/
async updateParticipante(id_participante: number, participante: UpdateParticipanteDto): Promise<Participante> {
// Verificar si el participante existe
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante
}
});
return {
success: true,
message: `Participante con ID ${id_participante} eliminado correctamente`,
affected: result.affected,
};
}
if (!participanteFound) {
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
/**
* Actualiza los datos de un participante
* @param id_participante ID del participante a actualizar
* @param participante Datos actualizados del participante
* @returns El participante actualizado
* @throws NotFoundException si no se encuentra el participante
* @throws BadRequestException si los datos son inválidos
*/
async updateParticipante(
id_participante: number,
participante: UpdateParticipanteDto,
): Promise<Participante> {
// Verificar si el participante existe
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante,
},
});
// Si se está actualizando el correo, verificar que no exista otro participante con ese correo
if (participante.correo && participante.correo !== participanteFound.correo) {
const existingParticipante = await this.participanteRepository.findOne({
where: {
correo: participante.correo
}
});
if (existingParticipante && existingParticipante.id_participante !== id_participante) {
throw new ConflictException(`Ya existe otro participante con el correo ${participante.correo}`);
}
}
// Actualizar el participante
const updateParticipante = Object.assign(participanteFound, participante);
return this.participanteRepository.save(updateParticipante);
if (!participanteFound) {
throw new NotFoundException(
`Participante con ID ${id_participante} no encontrado`,
);
}
// Si se está actualizando el correo, verificar que no exista otro participante con ese correo
if (
participante.correo &&
participante.correo !== participanteFound.correo
) {
const existingParticipante = await this.participanteRepository.findOne({
where: {
correo: participante.correo,
},
});
if (
existingParticipante &&
existingParticipante.id_participante !== id_participante
) {
throw new ConflictException(
`Ya existe otro participante con el correo ${participante.correo}`,
);
}
}
// Actualizar el participante
const updateParticipante = Object.assign(participanteFound, participante);
return this.participanteRepository.save(updateParticipante);
}
}
@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegistrarAsistenciaQrDto {
@ApiProperty({
description: 'Token JWT del código QR escaneado',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
qr_token: string;
}
@@ -0,0 +1,40 @@
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class ParticipanteEvento {
@PrimaryGeneratedColumn()
id_participante_evento: number;
@Column()
id_participante: number;
@Column()
id_cuestionario: number;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
fecha_registro: Date;
@Column({ type: 'datetime', nullable: true })
fecha_asistencia: Date;
@Column({ type: 'boolean', default: false })
asistio: boolean;
// Relaciones
@ManyToOne(() => Participante, (p) => p.participanteEventos)
@JoinColumn({ name: 'id_participante' })
participante: Participante;
@ManyToOne(() => Cuestionario, (c) => c.inscripciones)
@JoinColumn({ name: 'id_cuestionario' })
cuestionario: Cuestionario;
}
@@ -1,186 +1,340 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ParticipanteEventoService } from './participante_evento.service';
import { ParticipanteEvento } from './participante_evento.entity';
import { ParticipanteEvento } from './entities/participante_evento.entity';
import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto';
import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { RegistrarAsistenciaQrDto } from './dto/registrar-asistencia-qr.dto';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiResponse,
ApiBody,
} from '@nestjs/swagger';
@ApiTags('Participantes-Eventos')
@Controller('participante-evento')
export class ParticipanteEventoController {
constructor(private participanteEventoService: ParticipanteEventoService) {}
constructor(private participanteEventoService: ParticipanteEventoService) {}
@ApiOperation({ summary: 'Obtener participantes por evento' })
@ApiParam({
name: 'id_cuestionario',
description: 'ID del evento',
type: 'number',
})
@ApiResponse({
status: 200,
description: 'Lista de participantes que están registrados en el evento',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
participante: {
type: 'object',
properties: {
id_participante: { type: 'number' },
correo: { type: 'string' },
id_tipo_user: { type: 'number' },
},
},
},
},
},
})
@ApiResponse({ status: 404, description: 'Evento no encontrado' })
@Get('evento/:id_cuestionario')
getParticipantesPorEvento(
@Param('id_cuestionario', ParseIntPipe) id_cuestionario: number,
) {
return this.participanteEventoService.getParticipantesPorCuestionario(
id_cuestionario,
);
}
@ApiOperation({ summary: 'Obtener participantes por evento' })
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Lista de participantes que están registrados en el evento',
schema: {
type: 'array',
items: {
@ApiOperation({ summary: 'Obtener eventos por participante' })
@ApiParam({
name: 'idParticipante',
description: 'ID del participante',
type: 'number',
})
@ApiResponse({
status: 200,
description: 'Lista de eventos en los que está registrado el participante',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: {
type: 'string',
format: 'date-time',
nullable: true,
},
evento: {
type: 'object',
properties: {
id_evento: { type: 'number' },
nombre_evento: { type: 'string' },
tipo_evento: { type: 'string' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' },
},
},
},
},
},
})
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
@Get('participante/:idParticipante')
getEventosPorParticipante(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
) {
return this.participanteEventoService.getEventosPorParticipante(
idParticipante,
);
}
@ApiOperation({
summary: 'Obtener un registro específico por IDs de participante y evento',
})
@ApiParam({
name: 'idParticipante',
description: 'ID del participante',
type: 'number',
})
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':idParticipante/:idEvento')
getParticipanteEventoEspecifico(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number,
) {
return this.participanteEventoService.getParticipanteEventoEspecifico(
idParticipante,
idEvento,
);
}
@ApiOperation({ summary: 'Obtener un registro 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',
})
@Post('decode-token')
decodeToken(@Body() body: { token: string }) {
return this.participanteEventoService.validToken(
body.token,
);
}
@ApiOperation({
summary: 'Registrar asistencia usando token QR',
})
@ApiResponse({
status: 200,
description: 'Asistencia registrada correctamente mediante token QR',
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
data: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
participante: { type: 'string' },
fecha_asistencia: { type: 'string', format: 'date-time' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
participante: {
type: 'object',
properties: {
id_participante: { type: 'number' },
correo: { type: 'string' },
id_tipo_user: { type: 'number' }
}
}
}
}
}
})
@ApiResponse({ status: 404, description: 'Evento no encontrado' })
@Get('evento/:idEvento')
getParticipantesPorEvento(@Param('idEvento', ParseIntPipe) idEvento: number) {
return this.participanteEventoService.getParticipantesPorEvento(idEvento);
}
id_cuestionario: { type: 'number' },
},
},
},
},
})
@ApiResponse({
status: 400,
description: 'Token QR inválido o expirado',
})
@ApiResponse({
status: 404,
description: 'Participante no encontrado o no registrado en el evento',
})
@Post('asistencia-qr')
registrarAsistenciaConToken(@Body() body: { qr_token: string }) {
return this.participanteEventoService.registrarAsistenciaConToken(
body.qr_token,
);
}
@ApiOperation({ summary: 'Obtener eventos por participante' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Lista de eventos en los que está registrado el participante',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
evento: {
type: 'object',
properties: {
id_evento: { type: 'number' },
nombre_evento: { type: 'string' },
tipo_evento: { type: 'string' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' }
}
}
}
}
}
})
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
@Get('participante/:idParticipante')
getEventosPorParticipante(@Param('idParticipante', ParseIntPipe) idParticipante: number) {
return this.participanteEventoService.getEventosPorParticipante(idParticipante);
}
@ApiOperation({ summary: '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: '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' })
@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)
}
@ApiOperation({ summary: 'Actualizar un registro participante-evento' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro actualizado correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Patch(':id')
updateParticipanteEvento(
@Param('id', ParseIntPipe) id_participante: number,
id_evento: number,
@Body() participanteEvento: UpdateParticipanteEventoDto,
) {
return this.participanteEventoService.updateParticipanteEvento(
id_participante,
id_evento,
participanteEvento,
);
}
}
@@ -1,41 +0,0 @@
import { Evento } from "src/evento/evento.entity";
import { Participante } from "src/participante/participante.entity";
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class ParticipanteEvento {
@PrimaryColumn()
id_participante: number
@PrimaryColumn()
id_evento: number
@Column()
fecha_inscripcion: Date
@Column()
estatus: boolean
@Column({ nullable: true, type: 'datetime' })
fecha_asistencia: Date
/*
@OneToOne(() => Qr, (qr) => qr.participanteEvento)
qr: Qr;
@OneToMany(() => ParticipanteEvento, (pe) => pe.evento)
participantes: ParticipanteEvento[];
@ManyToOne(() => Evento, (evento) => evento.participantes)
@JoinColumn({ name: "id_evento" })
evento: Evento;
*/
@ManyToOne(() => Participante, participante => participante.participanteEventos)
@JoinColumn({ name: "id_participante" })
participante: Participante;
@ManyToOne(() => Evento, evento => evento.participanteEventos)
@JoinColumn({ name: "id_evento" })
evento: Evento;
}
@@ -2,13 +2,19 @@ import { Module } from '@nestjs/common';
import { ParticipanteEventoService } from './participante_evento.service';
import { ParticipanteEventoController } from './participante_evento.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ParticipanteEvento } from './participante_evento.entity';
import { Evento } from 'src/evento/evento.entity';
import { Participante } from 'src/participante/participante.entity';
import { ParticipanteEvento } from './entities/participante_evento.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity';
import { CuestionarioModule } from 'src/cuestionario/cuestionario.module';
import { QrModule } from 'src/qr/qr.module';
@Module({
imports: [TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante])],
controllers: [ParticipanteEventoController],
imports: [
TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante]),
CuestionarioModule,
QrModule,
],
controllers: [ParticipanteEventoController],
providers: [ParticipanteEventoService],
exports: [ParticipanteEventoService],
})
@@ -1,160 +1,276 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ParticipanteEvento } from './participante_evento.entity';
import { ParticipanteEvento } from './entities/participante_evento.entity';
import { Repository } from 'typeorm';
import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto';
import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto';
import { Evento } from 'src/evento/evento.entity';
import { Participante } from 'src/participante/participante.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity';
import { CuestionarioService } from 'src/cuestionario/cuestionario.service';
import { QrTokenService } from 'src/qr/qr-token.service';
@Injectable()
export class ParticipanteEventoService {
constructor(
@InjectRepository(ParticipanteEvento)
private participanteEventoRepository: Repository<ParticipanteEvento>,
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
private readonly cuestionarioService: CuestionarioService,
private readonly qrTokenService: QrTokenService,
) {}
constructor(
@InjectRepository(ParticipanteEvento) private participanteEventoRepository: Repository<ParticipanteEvento>,
@InjectRepository(Evento) private eventoRepository: Repository<Evento>,
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
) {}
async createParticipanteEvento(
participanteEvento: CreateParticipanteEventoDto,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante: participanteEvento.id_participante,
id_cuestionario: participanteEvento.id_evento,
},
});
async createParticipanteEvento(participanteEvento: CreateParticipanteEventoDto) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante: participanteEvento.id_participante,
id_evento: participanteEvento.id_evento
}
})
if (participante_eventoFound) {
return new HttpException('Participante already exists in this event', HttpStatus.CONFLICT)
}
return this.participanteEventoRepository.save(participanteEvento)
if (participante_eventoFound) {
return new HttpException(
'Participante already exists in this event',
HttpStatus.CONFLICT,
);
}
getParticipantesEvento() {
return this.participanteEventoRepository.find({
relations: ['evento', 'participante']
})
return this.participanteEventoRepository.save(participanteEvento);
}
getParticipantesEvento() {
return this.participanteEventoRepository.find({
relations: ['evento', 'participante'],
});
}
async getParticipanteEvento(id_participante: number) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
},
relations: ['evento', 'participante'],
});
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
async getParticipanteEvento(id_participante: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante
},
relations: ['evento', 'participante']
})
return participante_eventoFound;
}
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
async getParticipanteEventoEspecifico(
id_participante: number,
id_evento: number,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_cuestionario: id_evento,
},
relations: ['participante'],
});
return participante_eventoFound
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
async getParticipanteEventoEspecifico(id_participante: number, id_evento: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
},
relations: ['evento', 'participante']
})
return participante_eventoFound;
}
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
/**
* Obtiene todos los participantes de un evento específico
* @param id_evento ID del evento
* @returns Lista de registros participante-evento con información del participante
*/
async getParticipantesPorCuestionario(id_cuestionario: number) {
const cuestionario =
await this.cuestionarioService.getCuestionarioOrFail(id_cuestionario);
return participante_eventoFound
const participantesEvento = await this.participanteEventoRepository.find({
where: { id_cuestionario },
relations: ['participante'],
});
return participantesEvento;
}
/**
* Obtiene todos los eventos de un participante específico
* @param id_participante ID del participante
* @returns Lista de registros participante-evento con información del evento
*/
async getEventosPorParticipante(id_participante: number) {
// Verificar si el participante existe
const participanteExists = await this.participanteRepository.findOne({
where: { id_participante },
});
if (!participanteExists) {
throw new HttpException(
`Participante con ID ${id_participante} no encontrado`,
HttpStatus.NOT_FOUND,
);
}
/**
* Obtiene todos los participantes de un evento específico
* @param id_evento ID del evento
* @returns Lista de registros participante-evento con información del participante
*/
async getParticipantesPorEvento(id_evento: number) {
// Verificar si el evento existe
const eventoExists = await this.eventoRepository.findOne({
where: { id_evento }
});
// Obtener todos los registros participante-evento para este participante
const eventosParticipante = await this.participanteEventoRepository.find({
where: { id_participante },
relations: ['evento'],
});
if (!eventoExists) {
throw new HttpException(`Evento con ID ${id_evento} no encontrado`, HttpStatus.NOT_FOUND);
}
return eventosParticipante;
}
// Obtener todos los registros participante-evento para este evento
const participantesEvento = await this.participanteEventoRepository.find({
where: { id_evento },
relations: ['participante']
});
async deleteParticipanteEvento(id_participante: number) {
const result = await this.participanteEventoRepository.delete({
id_participante,
});
return participantesEvento;
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
/**
* Obtiene todos los eventos de un participante específico
* @param id_participante ID del participante
* @returns Lista de registros participante-evento con información del evento
*/
async getEventosPorParticipante(id_participante: number) {
// Verificar si el participante existe
const participanteExists = await this.participanteRepository.findOne({
where: { id_participante }
});
return result;
}
if (!participanteExists) {
throw new HttpException(`Participante con ID ${id_participante} no encontrado`, HttpStatus.NOT_FOUND);
}
async updateParticipanteEvento(
id_participante: number,
id_evento: number,
participante_evento: UpdateParticipanteEventoDto,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_cuestionario: id_evento,
},
});
// Obtener todos los registros participante-evento para este participante
const eventosParticipante = await this.participanteEventoRepository.find({
where: { id_participante },
relations: ['evento']
});
return eventosParticipante;
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
async deleteParticipanteEvento(id_participante: number) {
const result = await this.participanteEventoRepository.delete({ id_participante })
return this.participanteEventoRepository.save(participante_evento);
}
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
}
async registrarAsistenciaSinToken(
id_participante: number,
id_cuestionario: number,
) {
const participante_eventoFound =
await this.participanteEventoRepository.findOne({
where: {
id_participante,
cuestionario: { id_cuestionario },
},
});
return result
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
async updateParticipanteEvento(id_participante: number, id_evento: number, participante_evento: UpdateParticipanteEventoDto) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
}
})
participante_eventoFound.fecha_asistencia = new Date();
participante_eventoFound.asistio = true;
return this.participanteEventoRepository.save(participante_eventoFound);
}
if (!participante_eventoFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
}
/**
* 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);
return this.participanteEventoRepository.save(participante_evento)
if (!tokenData) {
return{
success: false,
message:
'El token ya caduco',
data: {}
}
}
const { id_participante, id_evento, id_cuestionario } = tokenData;
// Verificar que el participante existe
const participante = await this.participanteRepository.findOne({
where: { id_participante },
});
if (!participante) {
throw new HttpException(
`Participante con ID ${id_participante} no encontrado`,
HttpStatus.NOT_FOUND,
);
}
async registrarAsistencia(id_participante: number, id_evento: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
}
});
// Buscar el registro participante-evento
const participanteEvento = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_cuestionario,
},
relations: ['participante'],
});
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
participante_eventoFound.fecha_asistencia = new Date();
return this.participanteEventoRepository.save(participante_eventoFound);
if (!participanteEvento) {
throw new HttpException(
'El participante no está registrado en este evento',
HttpStatus.NOT_FOUND,
);
}
// Verificar si ya había registrado asistencia
if (participanteEvento.asistio && participanteEvento.fecha_asistencia) {
return {
success: false,
message:
'El participante ya había registrado su asistencia previamente',
data: {
participante: participante.correo,
fecha_asistencia_previa: participanteEvento.fecha_asistencia,
fecha_actual: new Date(),
},
};
}
// Registrar la asistencia
participanteEvento.fecha_asistencia = new Date();
participanteEvento.asistio = true;
const resultado =
await this.participanteEventoRepository.save(participanteEvento);
return {
success: true,
message: 'Asistencia registrada exitosamente',
data: {
participante: participante.correo,
fecha_asistencia: resultado.fecha_asistencia,
id_evento,
id_cuestionario,
},
};
}
async validToken(qrToken: string) {
// Validar el token QR
const tokenData = this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) {
return null;
}
return tokenData
}
}
+7 -20
View File
@@ -1,12 +1,9 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { TiposValidacion } from '../entities/pregunta.entity';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
export enum TipoPreguntaEnum {
CERRADA = 'Cerrada',
ABIERTA = 'Abierta',
MULTIPLE = 'Multiple'
}
export class OpcionDto {
@ApiProperty({
@@ -49,19 +46,18 @@ export class CreatePreguntaDto {
description: 'Tipo de pregunta',
enum: Object.values(TipoPreguntaEnum),
enumName: 'TipoPreguntaEnum',
example: TipoPreguntaEnum.CERRADA,
examples: {
'cerrada': {
summary: 'Pregunta de opción única',
value: TipoPreguntaEnum.CERRADA
value: TipoPreguntaEnum.Cerrada
},
'abierta': {
summary: 'Pregunta de texto libre',
value: TipoPreguntaEnum.ABIERTA
value: TipoPreguntaEnum.AbiertaParrafo
},
'multiple': {
summary: 'Pregunta de selección múltiple',
value: TipoPreguntaEnum.MULTIPLE
value: TipoPreguntaEnum.Multiple
}
}
})
@@ -69,7 +65,7 @@ export class CreatePreguntaDto {
@IsEnum(TipoPreguntaEnum, {
message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple'
})
tipo: string;
tipo: TipoPreguntaEnum;
@ApiProperty({
description: 'Contador de opciones (se calcula automáticamente)',
@@ -79,15 +75,6 @@ export class CreatePreguntaDto {
@IsNumber()
contador_opcion?: number;
@ApiProperty({
description: 'ID de la opción de la que depende esta pregunta (para preguntas condicionadas)',
required: false,
example: null
})
@IsOptional()
@IsNumber()
id_opcion_dependiente?: number;
@ApiProperty({
description: 'Tipo de validación para la respuesta',
required: false,
@@ -96,7 +83,7 @@ export class CreatePreguntaDto {
})
@IsOptional()
@IsString()
validacion?: string;
validacion?: TiposValidacion;
@ApiProperty({
description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple',
+38 -11
View File
@@ -1,8 +1,33 @@
import { IsEnum, IsOptional } from 'class-validator';
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity';
import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity';
import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
export enum TiposValidacion {
CORREO = 'correo',
CORREO_INSTITUCIONAL = 'correo_institucional',
TELEFONO = 'telefono',
NOMBRE = 'nombre',
APELLIDOS = 'apellidos',
ENTERO = 'entero',
DECIMAL = 'decimal',
COMUNIDAD_ALUMNO = 'comunidad_alumno',
CUENTA_ALUMNO = 'cuenta_alumno',
COMUNIDAD_TRABAJADOR = 'comunidad_trabajador',
CUENTA_TRABAJADOR = 'cuenta_trabajador',
GENERO = 'genero',
CARRERA = 'carrera',
INSTITUCION = 'institucion',
RFC = 'rfc',
}
@Entity()
export class Pregunta {
@@ -18,22 +43,24 @@ export class Pregunta {
@Column({ default: false })
obligatoria: boolean;
@ManyToOne(() => TipoPregunta, tipo => tipo.preguntas)
@ManyToOne(() => TipoPregunta, (tipo) => tipo.preguntas)
@JoinColumn({ name: 'id_tipo_pregunta' })
tipoPregunta: TipoPregunta;
@Column()
id_tipo_pregunta: number;
@Column({ type: 'int', nullable: true })
id_opcion_dependiente?: number;
@Column({ type: 'enum', enum: TiposValidacion, nullable: true })
@IsOptional()
@IsEnum(TiposValidacion, { message: 'Validación no permitida' })
validacion?: TiposValidacion;
@Column({ nullable: true })
validacion?: string;
@OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta)
@OneToMany(
() => SeccionPregunta,
(seccionPregunta) => seccionPregunta.pregunta,
)
seccionPreguntas: SeccionPregunta[];
@OneToMany(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.pregunta)
@OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.pregunta)
opciones: PreguntaOpcion[];
}
}
-20
View File
@@ -1,20 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PreguntaController } from './pregunta.controller';
import { PreguntaService } from './pregunta.service';
describe('PreguntaController', () => {
let controller: PreguntaController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PreguntaController],
providers: [PreguntaService],
}).compile();
controller = module.get<PreguntaController>(PreguntaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+81 -74
View File
@@ -1,6 +1,12 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
import { TipoPreguntaEnum } from './dto/create-pregunta.dto';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
export class PreguntaApiDocumentation {
// Decoradores para toda la clase del controlador
@@ -10,7 +16,7 @@ export class PreguntaApiDocumentation {
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Crear una nueva pregunta',
description: 'Crea una nueva pregunta para una sección'
description: 'Crea una nueva pregunta para una sección',
}),
ApiBody({
description: 'Datos de la pregunta a crear',
@@ -20,29 +26,30 @@ export class PreguntaApiDocumentation {
properties: {
titulo: { type: 'string', example: '¿Eres parte de la comunidad?' },
obligatoria: { type: 'boolean', example: true },
tipo: {
type: 'string',
tipo: {
type: 'string',
enum: Object.values(TipoPreguntaEnum),
description: 'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)',
example: TipoPreguntaEnum.CERRADA
description:
'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)',
example: TipoPreguntaEnum.Cerrada,
},
contador_opcion: { type: 'number', example: 0 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: {
type: 'string',
description: 'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)',
example: 'correo'
validacion: {
type: 'string',
description:
'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)',
example: 'correo',
},
opciones: {
type: 'array',
items: {
type: 'object',
properties: {
valor: { type: 'string', example: 'Si' }
}
}
}
}
valor: { type: 'string', example: 'Si' },
},
},
},
},
},
examples: {
cerrada: {
@@ -50,41 +57,38 @@ export class PreguntaApiDocumentation {
value: {
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
obligatoria: true,
tipo: TipoPreguntaEnum.CERRADA,
tipo: TipoPreguntaEnum.Cerrada,
validacion: null,
opciones: [
{ valor: 'Sí' },
{ valor: 'No' }
]
}
opciones: [{ valor: 'Sí' }, { valor: 'No' }],
},
},
abierta: {
summary: 'Pregunta de texto libre',
value: {
titulo: '¿Qué mejorarías en nuestro servicio?',
obligatoria: false,
tipo: TipoPreguntaEnum.ABIERTA,
validacion: null
}
tipo: TipoPreguntaEnum.AbiertaParrafo,
validacion: null,
},
},
multiple: {
summary: 'Pregunta de selección múltiple',
value: {
titulo: '¿Qué aspectos del servicio fueron de tu agrado?',
obligatoria: false,
tipo: TipoPreguntaEnum.MULTIPLE,
tipo: TipoPreguntaEnum.Multiple,
validacion: null,
opciones: [
{ valor: 'Rapidez' },
{ valor: 'Atención al cliente' },
{ valor: 'Facilidad de uso' }
]
}
}
}
{ valor: 'Facilidad de uso' },
],
},
},
},
}),
ApiResponse({
status: 201,
ApiResponse({
status: 201,
description: 'Pregunta creada correctamente',
schema: {
type: 'object',
@@ -94,20 +98,19 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' }
}
}
validacion: { type: 'string', example: 'correo' },
},
},
}),
ApiResponse({ status: 400, description: 'Datos de la pregunta inválidos' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para obtener todas las preguntas
static ApiGetAll = applyDecorators(
ApiOperation({
summary: 'Obtener todas las preguntas',
description: 'Retorna una lista de todas las preguntas registradas'
description: 'Retorna una lista de todas las preguntas registradas',
}),
ApiResponse({
status: 200,
@@ -118,31 +121,33 @@ export class PreguntaApiDocumentation {
type: 'object',
properties: {
id_pregunta: { type: 'number', example: 1 },
pregunta: { type: 'string', example: '¿Eres parte de la comunidad?' },
pregunta: {
type: 'string',
example: '¿Eres parte de la comunidad?',
},
obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' }
}
}
}
validacion: { type: 'string', example: 'correo' },
},
},
},
}),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para obtener una pregunta por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener una pregunta por ID',
description: 'Retorna una pregunta específica por su ID'
description: 'Retorna una pregunta específica por su ID',
}),
ApiParam({
name: 'id',
description: 'ID de la pregunta',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -155,7 +160,6 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' },
opciones: {
type: 'array',
@@ -163,29 +167,29 @@ export class PreguntaApiDocumentation {
type: 'object',
properties: {
id_opcion: { type: 'number', example: 1 },
opcion: { type: 'string', example: 'Si' }
}
}
}
}
}
opcion: { type: 'string', example: 'Si' },
},
},
},
},
},
}),
ApiResponse({ status: 404, description: 'Pregunta no encontrada' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para actualizar una pregunta
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar una pregunta',
description: 'Actualiza los datos de una pregunta existente'
description: 'Actualiza los datos de una pregunta existente',
}),
ApiParam({
name: 'id',
description: 'ID de la pregunta a actualizar',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiBody({
description: 'Datos a actualizar de la pregunta',
@@ -193,9 +197,9 @@ export class PreguntaApiDocumentation {
type: 'object',
properties: {
pregunta: { type: 'string', example: 'Pregunta actualizada' },
obligatoria: { type: 'boolean', example: false }
}
}
obligatoria: { type: 'boolean', example: false },
},
},
}),
ApiResponse({
status: 200,
@@ -203,27 +207,30 @@ export class PreguntaApiDocumentation {
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({
status: 400,
description: 'Datos de actualización inválidos',
}),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Pregunta no encontrada' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
// Documentación para eliminar una pregunta
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar una pregunta',
description: 'Elimina permanentemente una pregunta por su ID'
description: 'Elimina permanentemente una pregunta por su ID',
}),
ApiParam({
name: 'id',
description: 'ID de la pregunta a eliminar',
required: true,
type: 'number',
example: 1
example: 1,
}),
ApiResponse({
status: 200,
@@ -231,11 +238,11 @@ export class PreguntaApiDocumentation {
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
affected: { type: 'number', example: 1 },
},
},
}),
ApiResponse({ status: 404, description: 'Pregunta no encontrada' }),
ApiResponse({ status: 500, description: 'Error interno del servidor' })
ApiResponse({ status: 500, description: 'Error interno del servidor' }),
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PreguntaService } from './pregunta.service';
describe('PreguntaService', () => {
let service: PreguntaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PreguntaService],
}).compile();
service = module.get<PreguntaService>(PreguntaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
-2
View File
@@ -42,7 +42,6 @@ export class PreguntaService {
pregunta.pregunta = createPreguntaDto.titulo;
pregunta.obligatoria = createPreguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = createPreguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = createPreguntaDto.opciones?.length || 0;
pregunta.validacion = createPreguntaDto.validacion || undefined;
@@ -158,7 +157,6 @@ export class PreguntaService {
// Actualizar los campos básicos de la pregunta
if (updatePreguntaDto.titulo) pregunta.pregunta = updatePreguntaDto.titulo;
if (updatePreguntaDto.obligatoria !== undefined) pregunta.obligatoria = updatePreguntaDto.obligatoria;
if (updatePreguntaDto.id_opcion_dependiente !== undefined) pregunta.id_opcion_dependiente = updatePreguntaDto.id_opcion_dependiente;
if (updatePreguntaDto.validacion !== undefined) pregunta.validacion = updatePreguntaDto.validacion;
// Si se proporciona un nuevo tipo de pregunta, actualizarlo
+12
View File
@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ValidateQrTokenDto {
@ApiProperty({
description: 'Token JWT del código QR',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
token: string;
}
+136
View File
@@ -0,0 +1,136 @@
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';
@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>,
) {}
/**
* 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;
} | 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("fecha fin:", fecha_fin2.fecha_fin);
console.log("este es el token", decoded);
const fecha_fin2 = await this.eventoREpo.findOne({
where: { id_evento:decoded.id_evento },
});
if (!fecha_fin2) {
throw new UnauthorizedException('Evento no encontrado');
}
const fechaFinEvento = new Date(fecha_fin2.fecha_fin); // UTC
const now = new Date();
console.log(fechaFinEvento.getTime())
console.log(now.getTime)
if (fechaFinEvento.getTime() < now.getTime()) {
throw new Error('El evento ha finalizado');
}
// Verificar que sea un token de tipo QR de asistencia
if (decoded.type !== 'qr_asistencia') {
return null;
}
return {
id_participante: decoded.id_participante,
id_evento: decoded.id_evento,
id_cuestionario: decoded.id_cuestionario,
type: decoded.type,
iat: decoded.iat,
exp: decoded.exp,
};
} catch (error) {
console.error('Error validando token QR:', error.message);
return null;
}
}
/**
* 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;
}
}
+61 -4
View File
@@ -10,14 +10,20 @@ import {
Query,
} from '@nestjs/common';
import { QrService } from './qr.service';
import { QrTokenService } from './qr-token.service';
import { Qr } from './qr.entity';
import { CreateQrDto } from './dto/create-qr.dto';
import { UpdateQrDto } from './dto/update.qr.dto';
import { ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
import { ValidateQrTokenDto } from './dto/validate-qr-token.dto';
import { ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
@ApiTags('QR')
@Controller('qr')
export class QrController {
constructor(private qrService: QrService) {}
constructor(
private qrService: QrService,
private qrTokenService: QrTokenService,
) {}
@Get('generate')
@ApiOperation({ summary: 'Genera un código QR a partir de un texto' })
@@ -31,16 +37,67 @@ export class QrController {
}
@Get('asistencia/:idParticipante/:idEvento')
@ApiOperation({ summary: 'Genera un código QR para asistencia con IDs de participante y evento' })
@ApiOperation({
summary:
'Genera un código QR para asistencia con IDs de participante y evento',
})
@ApiParam({ name: 'idParticipante', description: 'ID del participante' })
@ApiParam({ name: 'idEvento', description: 'ID del evento' })
async generateAsistenciaQR(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number
@Param('idEvento', ParseIntPipe) idEvento: number,
): Promise<string> {
return this.qrService.generateAsistenciaQR(idParticipante, idEvento);
}
@Post('validate-token')
@ApiOperation({ summary: 'Valida un token de QR para asistencia' })
async validateQrToken(@Body() validateDto: ValidateQrTokenDto) {
const tokenData = this.qrTokenService.validateQrToken(validateDto.token);
if (!tokenData) {
return {
valid: false,
message: 'Token inválido o expirado',
};
}
return {
valid: true,
data: tokenData,
message: 'Token válido',
};
}
@Get('generate-token/:idParticipante/:idEvento/:idCuestionario/:fechaFin')
@ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante' })
@ApiParam({ name: 'idEvento', description: 'ID del evento' })
@ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' })
@ApiParam({ name: 'fechaFin', description: 'Fecha de fin de validez del token (ISO 8601)' })
async generateQrToken(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number,
@Param('idCuestionario', ParseIntPipe) idCuestionario: number,
@Param('fechaFin') fechaFin: Date,
) {
const token = await this.qrTokenService.generateQrToken(
idParticipante,
idEvento,
idCuestionario,
fechaFin
);
return {
token,
qr_data: {
id_participante: idParticipante,
id_evento: idEvento,
id_cuestionario: idCuestionario,
},
};
}
@Get()
getQrs(): Promise<Qr[]> {
return this.qrService.getQrs();
+9 -2
View File
@@ -1,12 +1,19 @@
import { Module } from '@nestjs/common';
import { QrService } from './qr.service';
import { QrController } from './qr.controller';
import { QrTokenService } from './qr-token.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Qr } from './qr.entity';
import { AuthModule } from '../auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import { Asistencia } from 'src/asistencia/entities/asistencia.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Evento } from 'src/evento/entities/evento.entity';
@Module({
imports: [TypeOrmModule.forFeature([Qr])],
imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario, Evento]), AuthModule, ConfigModule],
controllers: [QrController],
providers: [QrService]
providers: [QrService, QrTokenService],
exports: [QrTokenService],
})
export class QrModule {}
@@ -1,7 +1,12 @@
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity';
import { Pregunta } from 'src/pregunta/entities/pregunta.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
@Entity('respuesta_participante_abierta')
export class RespuestaParticipanteAbierta {
@@ -13,8 +18,8 @@ export class RespuestaParticipanteAbierta {
// Relación con CuestionarioRespondido
@ManyToOne(
() => CuestionarioRespondido,
cuestionarioRespondido => cuestionarioRespondido.respuestasAbiertas
() => CuestionarioRespondido,
(cuestionarioRespondido) => cuestionarioRespondido.respuestasAbiertas,
)
@JoinColumn({ name: 'id_cuestionario_respondido' })
cuestionarioRespondido: CuestionarioRespondido;
@@ -22,6 +27,4 @@ export class RespuestaParticipanteAbierta {
@ManyToOne(() => Pregunta)
@JoinColumn({ name: 'id_pregunta' })
pregunta: Pregunta;
}
}
@@ -1,20 +1,29 @@
import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity';
import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
@Entity('respuesta_participante_cerrada')
export class RespuestaParticipanteCerrada {
@PrimaryGeneratedColumn()
idRespuestaParticipanteCerrada: number;
@ManyToOne(() => CuestionarioRespondido, cuestionarioRespondido => cuestionarioRespondido.respuestasCerradas)
@ManyToOne(
() => CuestionarioRespondido,
(cuestionarioRespondido) => cuestionarioRespondido.respuestasCerradas,
)
@JoinColumn({ name: 'id_cuestionario_respondido' })
cuestionarioRespondido: CuestionarioRespondido;
@ManyToOne(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.respuestaParticipanteCerrada)
@ManyToOne(
() => PreguntaOpcion,
(preguntaOpcion) => preguntaOpcion.respuestaParticipanteCerrada,
)
@JoinColumn({ name: 'id_pregunta_opcion' })
preguntaOpcion: PreguntaOpcion;
}
}
+47 -45
View File
@@ -25,7 +25,7 @@ export class SeccionService {
private opcionRepository: Repository<Opcion>,
@InjectRepository(PreguntaOpcion)
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
private dataSource: DataSource
private dataSource: DataSource,
) {}
async create(createSeccionDto: CreateSeccionDto) {
@@ -39,58 +39,59 @@ export class SeccionService {
seccion.titulo = createSeccionDto.titulo;
seccion.descripcion = createSeccionDto.descripcion;
seccion.contador_pregunta = createSeccionDto.preguntas?.length || 0;
const savedSeccion = await queryRunner.manager.save(seccion);
// 2. Crear preguntas y vincularlas a la sección
if (createSeccionDto.preguntas && createSeccionDto.preguntas.length > 0) {
for (let i = 0; i < createSeccionDto.preguntas.length; i++) {
const preguntaDto = createSeccionDto.preguntas[i];
// Obtener el tipo de pregunta por su nombre
const tipoPregunta = await queryRunner.manager.findOne(TipoPregunta, {
where: { tipo_pregunta: preguntaDto.tipo },
});
if (!tipoPregunta) {
throw new Error(`Tipo de pregunta '${preguntaDto.tipo}' no encontrado`);
throw new Error(
`Tipo de pregunta '${preguntaDto.tipo}' no encontrado`,
);
}
// Crear pregunta
const pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo;
pregunta.obligatoria = preguntaDto.obligatoria || false;
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
const savedPregunta = await queryRunner.manager.save(pregunta);
// Vincular pregunta con sección
const seccionPregunta = new SeccionPregunta();
seccionPregunta.id_seccion = savedSeccion.id_seccion;
seccionPregunta.id_pregunta = savedPregunta.id_pregunta;
seccionPregunta.posicion = i + 1;
await queryRunner.manager.save(seccionPregunta);
// Crear opciones para preguntas de tipo multiple/radio
if (preguntaDto.opciones && preguntaDto.opciones.length > 0) {
for (let j = 0; j < preguntaDto.opciones.length; j++) {
const opcionDto = preguntaDto.opciones[j];
// Crear opción
const opcion = new Opcion();
opcion.opcion = opcionDto.valor;
const savedOpcion = await queryRunner.manager.save(opcion);
// Vincular opción con pregunta
const preguntaOpcion = new PreguntaOpcion();
preguntaOpcion.id_pregunta = savedPregunta.id_pregunta;
preguntaOpcion.opcion = savedOpcion;
preguntaOpcion.posicion = j + 1;
await queryRunner.manager.save(preguntaOpcion);
}
}
@@ -98,7 +99,7 @@ export class SeccionService {
}
await queryRunner.commitTransaction();
return {
success: true,
seccion: savedSeccion,
@@ -116,90 +117,91 @@ export class SeccionService {
}
async findOne(id: number) {
const seccion = await this.seccionRepository.findOne({
where: { id_seccion: id }
const seccion = await this.seccionRepository.findOne({
where: { id_seccion: id },
});
if (!seccion) {
throw new NotFoundException(`Sección con ID ${id} no encontrada`);
}
return seccion;
}
async findWithPreguntas(id: number) {
const seccion = await this.seccionRepository.findOne({
where: { id_seccion: id }
where: { id_seccion: id },
});
if (!seccion) {
throw new NotFoundException(`Sección con ID ${id} no encontrada`);
}
// Obtener las relaciones seccion-pregunta para esta sección
const seccionPreguntas = await this.seccionPreguntaRepository.find({
where: { id_seccion: id },
relations: ['pregunta']
relations: ['pregunta'],
});
// Extraer y organizar las preguntas
const preguntas = await Promise.all(
seccionPreguntas.map(async (sp) => {
const pregunta = await this.preguntaRepository.findOne({
where: { id_pregunta: sp.id_pregunta }
where: { id_pregunta: sp.id_pregunta },
});
// Obtener las opciones para preguntas de tipo multiple/radio
const preguntaOpciones = await this.preguntaOpcionRepository.find({
where: { id_pregunta: sp.id_pregunta },
relations: ['opcion'],
order: { posicion: 'ASC' }
order: { posicion: 'ASC' },
});
const opciones = preguntaOpciones.map(po => po.opcion);
const opciones = preguntaOpciones.map((po) => po.opcion);
return {
...pregunta,
posicion: sp.posicion,
opciones
opciones,
};
})
}),
);
// Ordenar las preguntas por posición
preguntas.sort((a, b) => a.posicion - b.posicion);
return {
...seccion,
preguntas
preguntas,
};
}
async update(id: number, updateSeccionDto: UpdateSeccionDto) {
const seccion = await this.seccionRepository.findOne({
where: { id_seccion: id }
where: { id_seccion: id },
});
if (!seccion) {
throw new NotFoundException(`Sección con ID ${id} no encontrada`);
}
// Actualizar solo los campos proporcionados
if (updateSeccionDto.titulo) seccion.titulo = updateSeccionDto.titulo;
if (updateSeccionDto.descripcion !== undefined) seccion.descripcion = updateSeccionDto.descripcion;
if (updateSeccionDto.descripcion !== undefined)
seccion.descripcion = updateSeccionDto.descripcion;
return this.seccionRepository.save(seccion);
}
async remove(id: number) {
const seccion = await this.seccionRepository.findOne({
where: { id_seccion: id }
where: { id_seccion: id },
});
if (!seccion) {
throw new NotFoundException(`Sección con ID ${id} no encontrada`);
}
return this.seccionRepository.remove(seccion);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Expose } from 'class-transformer';
export class TipoCuestionarioOutputDto {
@Expose()
id_tipo_cuestionario: number;
@Expose()
tipo_cuestionario: string;
}
@@ -1,14 +1,27 @@
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, ManyToOne } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
} from 'typeorm';
export enum TipoCuestionarioEnum {
REGISTRO = 'Registro',
EVALUACION = 'Evaluación',
}
@Entity('tipo_cuestionario')
export class TipoCuestionario {
@PrimaryGeneratedColumn()
id_tipo_cuestionario: number;
@Column({ type: 'varchar', length: 30 })
tipo_cuestionario: string;
@Column({ type: 'enum', enum: TipoCuestionarioEnum })
tipo_cuestionario: TipoCuestionarioEnum;
@ManyToOne(()=> Cuestionario,(cuestionario)=> cuestionario.id_tipo_cuestionario)
cuestionario:Cuestionario[];
@OneToMany(
() => Cuestionario,
(cuestionario) => cuestionario.tipoCuestionario,
)
cuestionarios: Cuestionario[];
}
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TipoCuestionarioService } from './tipo_cuestionario.service';
import { TipoCuestionarioController } from './tipo_cuestionario.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TipoCuestionario } from './entities/tipo_cuestionario.entity';
@Module({
imports: [TypeOrmModule.forFeature([TipoCuestionario])],
controllers: [TipoCuestionarioController],
providers: [TipoCuestionarioService],
})
@@ -1,9 +1,40 @@
import { Injectable } from '@nestjs/common';
import { CreateTipoCuestionarioDto } from './dto/create-tipo_cuestionario.dto';
import { UpdateTipoCuestionarioDto } from './dto/update-tipo_cuestionario.dto';
import { InjectRepository } from '@nestjs/typeorm';
import {
TipoCuestionario,
TipoCuestionarioEnum,
} from './entities/tipo_cuestionario.entity';
import { Repository } from 'typeorm';
@Injectable()
export class TipoCuestionarioService {
constructor(
@InjectRepository(TipoCuestionario)
private tipoCuestionarioRepository: Repository<TipoCuestionario>,
) {}
async onModuleInit() {
await this.seedTipoCuestionarios();
}
private async seedTipoCuestionarios() {
const enumValues = Object.values(TipoCuestionarioEnum);
for (const tipoCuestionario of enumValues) {
const existingTipo = await this.tipoCuestionarioRepository.findOne({
where: { tipo_cuestionario: tipoCuestionario },
});
if (!existingTipo) {
await this.tipoCuestionarioRepository.save({
tipo_cuestionario: tipoCuestionario,
});
}
}
}
create(createTipoCuestionarioDto: CreateTipoCuestionarioDto) {
return 'This action adds a new tipoCuestionario';
}
@@ -0,0 +1,8 @@
import { IsEnum, IsNotEmpty } from 'class-validator';
import { TipoEventoEnum } from '../entities/tipo_evento.entity';
export class CreateTipoEventoDto {
@IsNotEmpty()
@IsEnum(TipoEventoEnum)
tipo_evento: TipoEventoEnum;
}

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