From 94002a3e5046e06c124e67eb0739d3b714e48aea Mon Sep 17 00:00:00 2001 From: miguel Date: Tue, 12 Aug 2025 20:00:18 -0600 Subject: [PATCH 1/2] Enhance email functionality by adding QR code generation and resend logic for participants who have already responded to the questionnaire. Update email template for event ticket confirmation with detailed registration information. --- .vscode/settings.json | 3 + .../cuestionario_respondido.service.ts | 75 ++++- src/emails/registro-evento-ticket.ts | 307 ++++++++++++++++++ src/evento/entities/evento.entity.ts | 7 +- src/evento/evento.service.ts | 10 +- 5 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/emails/registro-evento-ticket.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..65a1965 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index ab64aee..fdc1a1e 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -101,9 +101,80 @@ export class CuestionarioRespondidoService { ); if (cuestionarioRespondidoExistente) { await queryRunner.rollbackTransaction(); + + // Aunque ya haya respondido, enviamos el correo nuevamente + if (cuestionario && cuestionario.evento) { + try { + // Generar datos para el QR + const datosQR = { + id_participante: participante.id_participante, + id_evento: cuestionario.evento.id_evento, + id_cuestionario: cuestionario.id_cuestionario, + }; + + // Generar código QR + const qrJsonData = JSON.stringify(datosQR); + const qrBuffer = await QRCode.toBuffer(qrJsonData); + + // Enviar correo con el QR + const urlApiCorreos = process.env.url_api_correos; + + if (urlApiCorreos) { + await axios.post( + `${urlApiCorreos}/mail/send`, + { + to: participante.correo, + subject: `Evento: ${cuestionario.evento?.nombre_evento}`, + fecha_recibido: new Date().toISOString(), + html: generarHtmlCorreoAsistencia({ + nombreForm: cuestionario.nombre_form, + nombreEvento: cuestionario.evento?.nombre_evento, + correo: participante.correo, + fechaRegistro: new Date().toLocaleString(), + fechaInicioEvento: cuestionario.evento?.fecha_inicio + ? new Date( + cuestionario.evento.fecha_inicio, + ).toLocaleString() + : undefined, + fechaFinEvento: cuestionario.evento?.fecha_fin + ? new Date(cuestionario.evento.fecha_fin).toLocaleString() + : undefined, + }), + adjuntos: [ + { + filename: 'qr.png', + content: qrBuffer.toString('base64'), + encoding: 'base64', + cid: 'qrCode', + }, + ], + }, + { + headers: { + token: process.env.SYSTEM_TOKEN_API_CORREOS, + }, + }, + ); + + console.log( + `Correo reenviado exitosamente a ${participante.correo}`, + ); + } + } catch (error) { + console.error('Error al reenviar correo:', error); + } + } + return { - success: false, - message: `El participante con correo ${submitDto.correo} ya ha respondido el cuestionario ${cuestionario.nombre_form}.`, + success: true, + message: `El participante con correo ${submitDto.correo} ya había respondido el cuestionario ${cuestionario.nombre_form}. Se ha reenviado el correo con el código QR.`, + datos_qr: cuestionario.evento + ? { + id_participante: participante.id_participante, + id_evento: cuestionario.evento.id_evento, + id_cuestionario: cuestionario.id_cuestionario, + } + : undefined, }; } diff --git a/src/emails/registro-evento-ticket.ts b/src/emails/registro-evento-ticket.ts new file mode 100644 index 0000000..19a5814 --- /dev/null +++ b/src/emails/registro-evento-ticket.ts @@ -0,0 +1,307 @@ +export function generarHtmlCorreoAsistenciaTicket({ + nombreForm, + nombreEvento, + correo, + fechaRegistro, + fechaInicioEvento, + fechaFinEvento, +}: { + nombreForm: string; + nombreEvento: string; + correo: string; + fechaRegistro: string; + fechaInicioEvento?: string; + fechaFinEvento?: string; +}) { + const horarioEvento = + fechaInicioEvento && fechaFinEvento + ? `${fechaInicioEvento} al ${fechaFinEvento}` + : 'Por confirmar'; + + return ` +
+ +
+

¡Registro Confirmado!

+
+ + +
+ +
+

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

+

+ TICKET DE ENTRADA • ENTRADA GRATUITA +

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

+ ESCANEA PARA REGISTRAR ASISTENCIA +

+
+
+ + +
+
+

+ 📋 Detalles de tu registro +

+ +
+
+

+ Participante +

+

+ ${correo} +

+
+ +
+

+ Formulario +

+

+ ${nombreForm} +

+
+ +
+

+ Fecha de Registro +

+

+ ${fechaRegistro} +

+
+ +
+

+ 🕒 Horario del Evento +

+

+ ${horarioEvento} +

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

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

+

+ Disponible en formato digital o impreso +

+
+
+

+ ENTRADA VÁLIDA +

+

+ ✓ Verificado +

+
+
+
+
+ + +
+

+ 📝 Instrucciones importantes: +

+
    +
  • Llega 15 minutos antes del inicio del evento
  • +
  • + Presenta este QR en la mesa de registro para validar tu asistencia +
  • +
  • Tu constancia será otorgada al finalizar el evento
  • +
  • Para dudas, acude al módulo de información durante el evento
  • +
+
+ + +
+

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

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