Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a156beab6 | |||
| 14d191790f | |||
| f496c2d8be | |||
| 8a837eda8d | |||
| aae1e5ede3 | |||
| 0c6f5336a9 | |||
| 2a807acb87 | |||
| a8645d28e8 | |||
| f7f6d8296c | |||
| f747e146e8 | |||
| 10ba9642bd | |||
| a2e4f62135 | |||
| 283b8df276 | |||
| a48f2cb41c | |||
| 564cbd4f69 | |||
| ed7cadd6e0 | |||
| d436ca6bbc | |||
| 489965681f | |||
| 297e9913bb | |||
| daca1aa0b0 | |||
| 60127068aa | |||
| 766a380f98 | |||
| d6d2c1c0d4 | |||
| 9997faadf9 | |||
| e3b2d3cb9c | |||
| c8667dc142 | |||
| 0805cffbab | |||
| 973eb625aa | |||
| f9cdf6a593 | |||
| abbbb206cf | |||
| 91cc3f4e10 | |||
| f1ef5c0b3c | |||
| 61b2a50c65 | |||
| 6ce45d5c1a | |||
| 8d1a9d3d53 | |||
| 24e07ea2e5 | |||
| 0c8981c81d | |||
| fdeb4e557f | |||
| 4e992c5b6e | |||
| e5c6f611f4 | |||
| d34fa32672 | |||
| 521adac6bf | |||
| bc07c307cf | |||
| c8056c5957 | |||
| 8208ff0292 | |||
| 934b4e4ffe | |||
| 1e58a64cde | |||
| 5ba3f0f2a8 | |||
| f50c9f36c8 | |||
| fa6a73ea1a | |||
| 8d191fbb23 | |||
| 1e13a5fffd | |||
| 7c3aba670e | |||
| c4d4fed43d |
@@ -1,10 +1,12 @@
|
||||
# Etapa de desarrollo para ejecutar en watch mode
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
# Instalar explícitamente las dependencias de autenticación
|
||||
RUN npm install bcryptjs @types/bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt @types/passport-jwt
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -12,7 +14,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Puerto expuesto
|
||||
EXPOSE 4200
|
||||
EXPOSE 4204
|
||||
|
||||
# Comando para iniciar en modo desarrollo
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
@@ -97,3 +97,25 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors
|
||||
## 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,6 +11,7 @@ services:
|
||||
MARIADB_DATABASE: formularios_test
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./init-scripts:/docker-entrypoint-initdb.d
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
@@ -24,10 +25,24 @@ services:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "4200:4200"
|
||||
- "4204:4204"
|
||||
depends_on:
|
||||
- mariadb
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
|
||||
front:
|
||||
build: ../formularios_front
|
||||
container_name: formularios_front
|
||||
restart: always
|
||||
depends_on:
|
||||
- api
|
||||
env_file:
|
||||
- ../formularios_front/.env
|
||||
ports:
|
||||
- "4202:3000"
|
||||
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
PORT=4200
|
||||
|
||||
|
||||
|
||||
db_type=mysql
|
||||
db_host=mariadb # <-- uso del nombre del servicio, NO IP
|
||||
db_port=3306
|
||||
db_username=root # <-- te conectarás con root
|
||||
db_password=mypass # <-- la misma que MARIADB_ROOT_PASSWORD
|
||||
db_database=formularios_test
|
||||
|
||||
|
||||
|
||||
url_api_correos=http://host.docker.internal:4100 #http://localhost:4100
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Crear la base de datos para el registro de alumnos
|
||||
CREATE DATABASE IF NOT EXISTS datos_feria_registro;
|
||||
USE datos_feria_registro;
|
||||
|
||||
-- Crear la tabla registro_alumno
|
||||
CREATE TABLE IF NOT EXISTS registro_alumno (
|
||||
id_ncuenta int(9) unsigned zerofill NOT NULL,
|
||||
nombre varchar(100) NOT NULL,
|
||||
apellidos varchar(120) NOT NULL,
|
||||
carrera varchar(100) NOT NULL,
|
||||
genero char(1) NOT NULL,
|
||||
PRIMARY KEY (id_ncuenta)
|
||||
);
|
||||
|
||||
-- Insertar algunos datos de ejemplo
|
||||
INSERT INTO registro_alumno (id_ncuenta, nombre, apellidos, carrera, genero) VALUES
|
||||
(000123456, 'Juan', 'Pérez López', 'Ingeniería en Computación', 'M'),
|
||||
(000789012, 'María', 'González Ramírez', 'Licenciatura en Administración', 'F'),
|
||||
(000456789, 'Carlos', 'Rodríguez Martínez', 'Ingeniería Civil', 'M'),
|
||||
(000345678, 'Ana', 'Sánchez Jiménez', 'Medicina', 'F'),
|
||||
(000901234, 'Pedro', 'Fernández Gómez', 'Arquitectura', 'M'),
|
||||
(316092940, 'Eithan', 'Fernández Gómez', 'Arquitectura', 'M');
|
||||
@@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
@@ -23,19 +23,29 @@
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.1.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"axios": "^1.8.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"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.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -47,6 +57,7 @@
|
||||
"@swc/core": "^1.10.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -81,5 +92,10 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +1,72 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { AdministradorService } from './administrador.service';
|
||||
import { Administrador } from './administrador.entity';
|
||||
import { CreateAdministradorDto } from './dto/create-administrador.dto';
|
||||
import { UpdateAdministradorDto } from './dto/update.administrador.dto';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { LoginAdministradorDto } from './dto/login-administrador.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdministradorApiDocumentation } from './administrador.documentation';
|
||||
|
||||
@ApiTags('Administradores') // Agrupa los endpoints en Swagger
|
||||
@Controller('administrador')
|
||||
@ApiBearerAuth()
|
||||
@AdministradorApiDocumentation.ApiController
|
||||
export class AdministradorController {
|
||||
|
||||
constructor(private administradorService: AdministradorService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Obtener todos los administradores' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de administradores obtenida correctamente.' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AdministradorApiDocumentation.ApiGetAll
|
||||
getAdministradores(): Promise<Administrador[]> {
|
||||
return this.administradorService.getAdministradores();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Obtener un administrador por ID' })
|
||||
@ApiParam({ name: 'id', description: 'ID del administrador', example: 1 })
|
||||
@ApiResponse({ status: 200, description: 'Administrador obtenido correctamente.' })
|
||||
@ApiResponse({ status: 404, description: 'Administrador no encontrado.' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AdministradorApiDocumentation.ApiGetOne
|
||||
getAdministrador(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.administradorService.getAdministrador(id)
|
||||
return this.administradorService.getAdministrador(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Registrar un nuevo administrador' })
|
||||
@ApiBody({
|
||||
description: 'Datos del administrador a registrar',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_tipo_user: { type: 'integer', example: 1 }
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Administrador registrado exitosamente.' })
|
||||
@ApiResponse({ status: 400, description: 'Datos inválidos.' })
|
||||
@AdministradorApiDocumentation.ApiCreate
|
||||
createAdministrador(@Body() newAdministrador: CreateAdministradorDto) {
|
||||
return this.administradorService.createAdministrador(newAdministrador)
|
||||
return this.administradorService.createAdministrador(newAdministrador);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@AdministradorApiDocumentation.ApiLogin
|
||||
login(@Body() loginData: LoginAdministradorDto) {
|
||||
return this.administradorService.login(loginData.correo, loginData.password);
|
||||
}
|
||||
|
||||
@Patch(':id/change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AdministradorApiDocumentation.ApiChangePassword
|
||||
changePassword(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() changePasswordDto: ChangePasswordDto
|
||||
) {
|
||||
return this.administradorService.changePassword(
|
||||
id,
|
||||
changePasswordDto.currentPassword,
|
||||
changePasswordDto.newPassword
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AdministradorApiDocumentation.ApiRemove
|
||||
deleteAdministrador(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.administradorService.deleteAdministrador(id)
|
||||
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)
|
||||
return this.administradorService.updateAdministrador(id, administrador);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { 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');
|
||||
|
||||
// Documentación para crear un administrador
|
||||
static ApiCreate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Registrar un nuevo administrador',
|
||||
description: 'Crea un nuevo administrador con correo, contraseña y tipo de administrador'
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos del administrador a registrar',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['correo', 'password', 'id_tipo_user'],
|
||||
properties: {
|
||||
correo: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'admin@ejemplo.com',
|
||||
description: 'Correo electrónico del administrador'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
example: 'Password123',
|
||||
description: 'Contraseña del administrador (mínimo 6 caracteres)'
|
||||
},
|
||||
id_tipo_user: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
description: 'ID del tipo de usuario administrador'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 201,
|
||||
description: 'Administrador registrado exitosamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_admnistrador: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'admin@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos del administrador inválidos' }),
|
||||
ApiResponse({ status: 409, description: 'El correo ya está registrado' })
|
||||
);
|
||||
|
||||
// Documentación para iniciar sesión
|
||||
static ApiLogin = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Iniciar sesión como administrador',
|
||||
description: 'Autentica un administrador con correo y contraseña, y devuelve un token JWT'
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Credenciales de inicio de sesión',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['correo', 'password'],
|
||||
properties: {
|
||||
correo: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'admin@ejemplo.com',
|
||||
description: 'Correo electrónico del administrador'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
example: 'Password123',
|
||||
description: 'Contraseña del administrador'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Inicio de sesión exitoso',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
access_token: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
description: 'Token JWT para autenticación'
|
||||
},
|
||||
id_administrador: {
|
||||
type: 'number',
|
||||
example: 1,
|
||||
description: 'ID del administrador autenticado'
|
||||
},
|
||||
correo: {
|
||||
type: 'string',
|
||||
example: 'admin@ejemplo.com',
|
||||
description: 'Correo del administrador autenticado'
|
||||
},
|
||||
tipo_user: {
|
||||
type: 'number',
|
||||
example: 1,
|
||||
description: 'Tipo de usuario del administrador'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 401, description: 'Credenciales inválidas' })
|
||||
);
|
||||
|
||||
// Documentación para obtener todos los administradores
|
||||
static ApiGetAll = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener todos los administradores',
|
||||
description: 'Retorna una lista de todos los administradores registrados (requiere autenticación)'
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Lista de administradores obtenida correctamente',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_admnistrador: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'admin@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipoUser: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: { type: 'string', example: 'Administrador General' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 401, description: 'No autorizado' })
|
||||
);
|
||||
|
||||
// Documentación para obtener un administrador por ID
|
||||
static ApiGetOne = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener un administrador por ID',
|
||||
description: 'Retorna los datos de un administrador específico según su ID (requiere autenticación)'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del administrador',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Administrador obtenido correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_admnistrador: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'admin@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipoUser: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: { type: 'string', example: 'Administrador General' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
|
||||
ApiResponse({ status: 401, description: 'No autorizado' })
|
||||
);
|
||||
|
||||
// Documentación para actualizar un administrador
|
||||
static ApiUpdate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Actualizar datos de un administrador',
|
||||
description: 'Actualiza la información de un administrador existente (requiere autenticación)'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del administrador a actualizar',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos a actualizar del administrador',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
correo: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'nuevo_admin@ejemplo.com',
|
||||
description: 'Nuevo correo electrónico del administrador'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
example: 'NuevaPassword123',
|
||||
description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
|
||||
},
|
||||
id_tipo_user: {
|
||||
type: 'integer',
|
||||
example: 2,
|
||||
description: 'Nuevo tipo de usuario administrador'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Administrador actualizado correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_admnistrador: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 2 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
|
||||
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
|
||||
ApiResponse({ status: 401, description: 'No autorizado' })
|
||||
);
|
||||
|
||||
// Documentación para cambiar contraseña
|
||||
static ApiChangePassword = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Cambiar contraseña de un administrador',
|
||||
description: 'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del administrador',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos para cambio de contraseña',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['currentPassword', 'newPassword'],
|
||||
properties: {
|
||||
currentPassword: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
example: 'Password123',
|
||||
description: 'Contraseña actual del administrador'
|
||||
},
|
||||
newPassword: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
example: 'NuevaPassword123',
|
||||
description: 'Nueva contraseña del administrador (mínimo 6 caracteres)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Contraseña actualizada correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Contraseña actualizada correctamente' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Contraseña actual incorrecta' }),
|
||||
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
|
||||
ApiResponse({ status: 401, description: 'No autorizado' })
|
||||
);
|
||||
|
||||
// Documentación para eliminar un administrador
|
||||
static ApiRemove = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Eliminar un administrador',
|
||||
description: 'Elimina permanentemente un administrador por su ID (requiere autenticación)'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del administrador a eliminar',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Administrador eliminado correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
affected: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Administrador no encontrado' }),
|
||||
ApiResponse({ status: 401, description: 'No autorizado' })
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,12 @@ 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" })
|
||||
|
||||
@@ -3,9 +3,13 @@ import { AdministradorService } from './administrador.service';
|
||||
import { AdministradorController } from './administrador.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Administrador } from './administrador.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Administrador])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Administrador]),
|
||||
AuthModule
|
||||
],
|
||||
controllers: [AdministradorController],
|
||||
providers: [AdministradorService],
|
||||
exports: [AdministradorService],
|
||||
|
||||
@@ -4,36 +4,115 @@ import { Administrador } from './administrador.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateAdministradorDto } from './dto/create-administrador.dto';
|
||||
import { UpdateAdministradorDto } from './dto/update.administrador.dto';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
@Injectable()
|
||||
export class AdministradorService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Administrador) private administradorRepository: Repository<Administrador>
|
||||
@InjectRepository(Administrador) private administradorRepository: Repository<Administrador>,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async createAdministrador(administrador: CreateAdministradorDto) {
|
||||
|
||||
//revisar el where
|
||||
// Verificar si ya existe un administrador con el mismo correo
|
||||
const administradorFound = await this.administradorRepository.findOne({
|
||||
where: {
|
||||
id_admnistrador: administrador.id_tipo_user
|
||||
correo: administrador.correo
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (administradorFound) {
|
||||
return new HttpException('Administrador already exists', HttpStatus.CONFLICT)
|
||||
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
|
||||
});
|
||||
|
||||
//Falta regresar el return
|
||||
//return this.administradorRepository.save(administradorFound)
|
||||
const savedAdministrador = await this.administradorRepository.save(newAdministrador);
|
||||
|
||||
// Excluir la contraseña de la respuesta
|
||||
const { password, ...result } = savedAdministrador;
|
||||
return result;
|
||||
}
|
||||
|
||||
async login(correo: string, password: string) {
|
||||
// Buscar administrador por correo
|
||||
const administrador = await this.administradorRepository.findOne({
|
||||
where: { correo },
|
||||
relations: ['tipoUser']
|
||||
});
|
||||
|
||||
if (!administrador) {
|
||||
throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Verificar contraseña
|
||||
const isPasswordValid = await bcrypt.compare(password, administrador.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt();
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
getAdministradores() {
|
||||
return this.administradorRepository.find({
|
||||
relations: ['tipoUser']
|
||||
})
|
||||
relations: ['tipoUser'],
|
||||
select: {
|
||||
id_admnistrador: true,
|
||||
correo: true,
|
||||
id_tipo_user: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAdministrador(id_admnistrador: number) {
|
||||
@@ -41,24 +120,29 @@ export class AdministradorService {
|
||||
where: {
|
||||
id_admnistrador
|
||||
},
|
||||
relations: ['tipoUser']
|
||||
})
|
||||
relations: ['tipoUser'],
|
||||
select: {
|
||||
id_admnistrador: true,
|
||||
correo: true,
|
||||
id_tipo_user: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!Administrador) {
|
||||
return new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
if (!administradorFound) {
|
||||
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return administradorFound
|
||||
return administradorFound;
|
||||
}
|
||||
|
||||
async deleteAdministrador(id_admnistrador: number) {
|
||||
const result = await this.administradorRepository.delete({ id_admnistrador })
|
||||
const result = await this.administradorRepository.delete({ id_admnistrador });
|
||||
|
||||
if (result.affected === 0) {
|
||||
return new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateAdministrador(id_admnistrador: number, administrador: UpdateAdministradorDto) {
|
||||
@@ -66,14 +150,22 @@ export class AdministradorService {
|
||||
where: {
|
||||
id_admnistrador
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!administradorFound) {
|
||||
return new HttpException('User not found', HttpStatus.NOT_FOUND)
|
||||
throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const updateAdministrador = Object.assign(administradorFound, administrador)
|
||||
return this.administradorRepository.save(updateAdministrador)
|
||||
}
|
||||
// Si se incluye una nueva contraseña, hashearla
|
||||
if (administrador.password) {
|
||||
administrador.password = await this.hashPassword(administrador.password);
|
||||
}
|
||||
|
||||
const updateAdministrador = Object.assign(administradorFound, administrador);
|
||||
const savedAdministrador = await this.administradorRepository.save(updateAdministrador);
|
||||
|
||||
// Excluir la contraseña de la respuesta
|
||||
const { password, ...result } = savedAdministrador;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: 'Contraseña actual del administrador' })
|
||||
@IsNotEmpty({ message: 'La contraseña actual es requerida' })
|
||||
currentPassword: string;
|
||||
|
||||
@ApiProperty({ description: 'Nueva contraseña del administrador' })
|
||||
@MinLength(6, { message: 'La nueva contraseña debe tener al menos 6 caracteres' })
|
||||
@IsNotEmpty({ message: 'La nueva contraseña es requerida' })
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
import { IsEmail, IsNotEmpty, IsNumber, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAdministradorDto {
|
||||
id_tipo_user: number
|
||||
@ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' })
|
||||
@IsEmail({}, { message: 'El correo no es válido' })
|
||||
@IsNotEmpty({ message: 'El correo es requerido' })
|
||||
correo: string;
|
||||
|
||||
@ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' })
|
||||
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
|
||||
@IsNotEmpty({ message: 'La contraseña es requerida' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'ID del tipo de usuario' })
|
||||
@IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
|
||||
@IsNotEmpty({ message: 'El tipo de usuario es requerido' })
|
||||
id_tipo_user: number;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsNotEmpty, MinLength } 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: 'Password123', description: 'Contraseña del administrador' })
|
||||
@IsNotEmpty({ message: 'La contraseña es requerida' })
|
||||
password: string;
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
import { IsEmail, IsNumber, IsOptional, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateAdministradorDto {
|
||||
id_tipo_user?: number
|
||||
@ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador', required: false })
|
||||
@IsEmail({}, { message: 'El correo no es válido' })
|
||||
@IsOptional()
|
||||
correo?: string;
|
||||
|
||||
@ApiProperty({ example: 'Password123', description: 'Contraseña del administrador', required: false })
|
||||
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'ID del tipo de usuario', required: false })
|
||||
@IsNumber({}, { message: 'El tipo de usuario debe ser un número' })
|
||||
@IsOptional()
|
||||
id_tipo_user?: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
|
||||
import { AlumnosService } from './alumnos.service';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
|
||||
import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
|
||||
|
||||
@ApiTags('alumnos')
|
||||
@Controller('alumnos')
|
||||
export class AlumnosController {
|
||||
constructor(private readonly alumnosService: AlumnosService) {}
|
||||
|
||||
@Get(':cuenta')
|
||||
@ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' })
|
||||
@ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' })
|
||||
@ApiResponse(ALUMNO_RESPONSES[200])
|
||||
@ApiResponse(ALUMNO_RESPONSES[404])
|
||||
@ApiResponse(ALUMNO_RESPONSES[500])
|
||||
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<AlumnoDto | null> {
|
||||
const alumno = await this.alumnosService.findByCuenta(cuenta);
|
||||
if (!alumno) {
|
||||
throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`);
|
||||
}
|
||||
return alumno;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AlumnoDto {
|
||||
@ApiProperty({
|
||||
example: 123456789,
|
||||
description: 'Número de cuenta del alumno (9 dígitos)',
|
||||
})
|
||||
id_ncuenta: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Juan',
|
||||
description: 'Nombre del alumno',
|
||||
})
|
||||
nombre: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Pérez López',
|
||||
description: 'Apellidos del alumno',
|
||||
})
|
||||
apellidos: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Ingeniería en Computación',
|
||||
description: 'Carrera que cursa el alumno',
|
||||
})
|
||||
carrera: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'M',
|
||||
description: 'Género del alumno (M: Masculino, F: Femenino)',
|
||||
enum: ['M', 'F'],
|
||||
})
|
||||
genero: string;
|
||||
}
|
||||
|
||||
export const ALUMNO_RESPONSES = {
|
||||
200: {
|
||||
description: 'Datos del alumno obtenidos correctamente',
|
||||
type: AlumnoDto,
|
||||
},
|
||||
404: {
|
||||
description: 'Alumno no encontrado',
|
||||
},
|
||||
500: {
|
||||
description: 'Error interno del servidor',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlumnosController } from './alumnos.controller';
|
||||
import { AlumnosService } from './alumnos.service';
|
||||
import { RegistroAlumno } from './entities/registro-alumno.entity';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
|
||||
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // importante si querés que esté disponible en todos lados
|
||||
envFilePath: '.env', // opcional si el archivo ya es '.env'
|
||||
}),
|
||||
|
||||
TypeOrmModule.forRootAsync({
|
||||
name: 'alumnosConnection',
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
host: configService.get<string>('ALUMNOS_DB_HOST'),
|
||||
port: Number(configService.get<number>('ALUMNOS_DB_PORT')),
|
||||
username: configService.get<string>('ALUMNOS_DB_USERNAME'),
|
||||
password: configService.get<string>('ALUMNOS_DB_PASSWORD'),
|
||||
database: configService.get<string>('ALUMNOS_DB_DATABASE'),
|
||||
entities: [RegistroAlumno],
|
||||
synchronize: false,
|
||||
retryAttempts: 10,
|
||||
retryDelay: 3000,
|
||||
connectTimeout: 30000,
|
||||
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
|
||||
/* TypeOrmModule.forRoot({
|
||||
name: 'alumnosConnection',
|
||||
type: 'mysql',
|
||||
host: process.env.ALUMNOS_DB_HOST ,
|
||||
port: Number(process.env.ALUMNOS_DB_PORT),
|
||||
username: process.env.ALUMNOS_DB_USERNAME ,
|
||||
password: process.env.ALUMNOS_DB_PASSWORD ,
|
||||
database: process.env.ALUMNOS_DB_DATABASE ,
|
||||
entities: [RegistroAlumno],
|
||||
synchronize: false,
|
||||
|
||||
retryAttempts: 10, // Intentos para reconectar
|
||||
retryDelay: 3000, // Tiempo entre reintentos
|
||||
connectTimeout: 30000, // Timeout de conexión (30 segundos) │ │
|
||||
|
||||
}), */
|
||||
],
|
||||
controllers: [AlumnosController],
|
||||
providers: [AlumnosService],
|
||||
exports: [AlumnosService],
|
||||
})
|
||||
export class AlumnosModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RegistroAlumno } from './entities/registro-alumno.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AlumnosService {
|
||||
constructor(
|
||||
@InjectRepository(RegistroAlumno, 'alumnosConnection')
|
||||
private registroAlumnoRepository: Repository<RegistroAlumno>,
|
||||
) {}
|
||||
|
||||
async findByCuenta(cuenta: string): Promise<RegistroAlumno | null> {
|
||||
return this.registroAlumnoRepository.findOne({
|
||||
where: { id_ncuenta: parseInt(cuenta, 10) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Entity, Column, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('registro_alumno')
|
||||
export class RegistroAlumno {
|
||||
@PrimaryColumn({ type: 'int', unsigned: true, zerofill: true, width: 9 })
|
||||
id_ncuenta: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
nombre: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
apellidos: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
carrera: string;
|
||||
|
||||
@Column({ type: 'char', length: 1 })
|
||||
genero: string;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { RespuestaParticipanteCerradaModule } from './respuesta_participante_cer
|
||||
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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventoModule } from './evento/evento.module';
|
||||
import { TipoUserModule } from './tipo_user/tipo_user.module';
|
||||
import { ParticipanteModule } from './participante/participante.module';
|
||||
@@ -23,27 +23,53 @@ 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 { RegistroAlumno } from './alumnos/entities/registro-alumno.entity';
|
||||
import { ImagenController } from './imagen/imagen.controller';
|
||||
import { ImagenModule } from './imagen/imagen.module';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: config.get<'mysql'>('DB_TYPE'),
|
||||
host: config.get<string>('DB_HOST'),
|
||||
port: +(config.get<number>('DB_PORT') || 3306),
|
||||
username: config.get<string>('DB_USERNAME'),
|
||||
password: config.get<string>('DB_PASSWORD'),
|
||||
database: config.get<string>('DB_DATABASE'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: true,
|
||||
//dropSchema: true, // elimina la base de datos
|
||||
}),
|
||||
|
||||
/*
|
||||
type: 'mysql',
|
||||
host: process.env.db_host, //process.env.db_host
|
||||
username: process.env.db_username,
|
||||
database: process.env.db_database,
|
||||
password: process.env.db_password,
|
||||
port: Number(process.env.db_port),
|
||||
synchronize: true,
|
||||
dropSchema: true, // elimina la base de datos
|
||||
*/
|
||||
|
||||
//synchronize: true,
|
||||
//dropSchema: true, // elimina la base de datos
|
||||
|
||||
// logging: true, // Habilita los logs para depuración
|
||||
autoLoadEntities: true, // Carga automáticamente las entidades
|
||||
//autoLoadEntities: true, // Carga automáticamente las entidades
|
||||
|
||||
//esta es mi base de datos local
|
||||
|
||||
/*
|
||||
/*
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306, //3306
|
||||
@@ -56,9 +82,11 @@ import { ParticipanteEventoModule } from './participante_evento/participante_eve
|
||||
|
||||
|
||||
//extra
|
||||
retryAttempts: 10, // Intentos para reconectar
|
||||
retryDelay: 3000, // Tiempo entre reintentos
|
||||
//retryAttempts: 10, // Intentos para reconectar
|
||||
//retryDelay: 3000, // Tiempo entre reintentos
|
||||
}),
|
||||
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
|
||||
AuthModule,
|
||||
CuestionarioModule,
|
||||
TipoCuestionarioModule,
|
||||
CuestionarioSeccionModule,
|
||||
@@ -77,6 +105,9 @@ import { ParticipanteEventoModule } from './participante_evento/participante_eve
|
||||
AdministradorModule,
|
||||
AsistenciaModule,
|
||||
ParticipanteEventoModule,
|
||||
ValidacionesModule,
|
||||
AlumnosModule,
|
||||
ImagenModule,
|
||||
],
|
||||
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'tu_clave_secreta',
|
||||
signOptions: {
|
||||
expiresIn: '24h', // Tokens expiran en 24 horas
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature([Administrador]),
|
||||
],
|
||||
providers: [JwtStrategy],
|
||||
exports: [PassportModule, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Administrador } from '../administrador/administrador.entity';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
@InjectRepository(Administrador)
|
||||
private administradorRepository: Repository<Administrador>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'tu_clave_secreta',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const { sub: id_admnistrador } = payload;
|
||||
|
||||
const administrador = await this.administradorRepository.findOne({
|
||||
where: { id_admnistrador },
|
||||
select: {
|
||||
id_admnistrador: true,
|
||||
correo: true,
|
||||
id_tipo_user: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!administrador) {
|
||||
throw new UnauthorizedException('Token inválido');
|
||||
}
|
||||
|
||||
return administrador;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
|
||||
import { feriaSexualidad } from '../../utils/crear_formulario_feria';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { FormularioDto } from './dto/formulario.schema';
|
||||
import { FormularioDto, FormularioFeriaSchema } from './dto/formulario.schema';
|
||||
|
||||
export class CuestionarioApiDocumentation {
|
||||
// Decoradores para toda la clase del controlador
|
||||
@@ -11,7 +11,7 @@ export class CuestionarioApiDocumentation {
|
||||
static ApiGetFormulario = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener formulario completo',
|
||||
description: 'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo'
|
||||
description: 'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo, incluyendo el atributo validacion para las preguntas que tengan reglas de validación específicas'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
@@ -22,7 +22,7 @@ export class CuestionarioApiDocumentation {
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Formulario completo con todas sus secciones, preguntas y opciones',
|
||||
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': {
|
||||
@@ -38,22 +38,22 @@ export class CuestionarioApiDocumentation {
|
||||
static ApiCreate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Crear un nuevo cuestionario',
|
||||
description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones'
|
||||
description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones, incluyendo validaciones personalizadas'
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos del cuestionario a crear',
|
||||
description: 'Datos del cuestionario a crear. Para las preguntas de tipo "Abierta", se puede especificar un tipo de validación usando la propiedad "validacion" con valores como: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
|
||||
type: FormularioDto,
|
||||
examples: {
|
||||
feriaSexualidad: {
|
||||
summary: 'Ejemplo de cuestionario para feria de sexualidad',
|
||||
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad',
|
||||
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad, ahora con validaciones específicas para cada tipo de campo (correo, nombre, cuenta_alumno, etc.)',
|
||||
value: feriaSexualidad
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 201,
|
||||
description: 'Cuestionario creado correctamente',
|
||||
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 },
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Opcion } from '../opcion/entities/opcion.entity';
|
||||
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
|
||||
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
|
||||
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
|
||||
import { Evento } from '../evento/evento.entity';
|
||||
import { EventoService } from '../evento/evento.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -23,11 +25,12 @@ import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionari
|
||||
Opcion,
|
||||
PreguntaOpcion,
|
||||
TipoPregunta,
|
||||
TipoCuestionario
|
||||
TipoCuestionario,
|
||||
Evento
|
||||
])
|
||||
],
|
||||
controllers: [CuestionarioController],
|
||||
providers: [CuestionarioService],
|
||||
providers: [CuestionarioService, EventoService],
|
||||
exports: [TypeOrmModule, CuestionarioService]
|
||||
})
|
||||
export class CuestionarioModule {}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Opcion } from '../opcion/entities/opcion.entity';
|
||||
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
|
||||
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
|
||||
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
|
||||
import { Evento } from '../evento/evento.entity';
|
||||
import { EventoService } from '../evento/evento.service';
|
||||
|
||||
@Injectable()
|
||||
export class CuestionarioService {
|
||||
@@ -32,7 +34,10 @@ export class CuestionarioService {
|
||||
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
|
||||
@InjectRepository(TipoPregunta)
|
||||
private tipoPreguntaRepository: Repository<TipoPregunta>,
|
||||
@InjectRepository(Evento)
|
||||
private eventoRepository: Repository<Evento>,
|
||||
private dataSource: DataSource,
|
||||
private eventoService: EventoService
|
||||
) {}
|
||||
|
||||
async create(createCuestionarioDto: CreateCuestionarioDto) {
|
||||
@@ -41,6 +46,33 @@ export class CuestionarioService {
|
||||
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;
|
||||
@@ -50,6 +82,11 @@ export class CuestionarioService {
|
||||
cuestionario.id_tipo_cuestionario = createCuestionarioDto.id_tipo_cuestionario;
|
||||
cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0;
|
||||
cuestionario.editable = true;
|
||||
|
||||
// Asociar el cuestionario con el evento si existe
|
||||
if (idEvento !== undefined) {
|
||||
cuestionario.id_evento = idEvento;
|
||||
}
|
||||
|
||||
const savedCuestionario = await queryRunner.manager.save(cuestionario);
|
||||
|
||||
@@ -79,15 +116,22 @@ export class CuestionarioService {
|
||||
for (let j = 0; j < seccionDto.preguntas.length; j++) {
|
||||
const preguntaDto = seccionDto.preguntas[j];
|
||||
|
||||
// Obtener el tipo de pregunta por su nombre
|
||||
const tipoPregunta = await queryRunner.manager.findOne(TipoPregunta, {
|
||||
where: { tipo_pregunta: preguntaDto.tipo },
|
||||
});
|
||||
// Mapear directamente el tipo de pregunta a su ID correspondiente
|
||||
let idTipoPregunta = 7; // Valor por defecto (desconocido)
|
||||
|
||||
if (!tipoPregunta) {
|
||||
throw new Error(`Tipo de pregunta '${preguntaDto.tipo}' no encontrado`);
|
||||
// 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.`);
|
||||
}
|
||||
|
||||
const tipoPregunta = { id_tipo: idTipoPregunta, tipo_pregunta: preguntaDto.tipo };
|
||||
|
||||
// Crear pregunta
|
||||
const pregunta = new Pregunta();
|
||||
pregunta.pregunta = preguntaDto.titulo;
|
||||
@@ -95,6 +139,7 @@ export class CuestionarioService {
|
||||
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
|
||||
pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined;
|
||||
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
|
||||
pregunta.validacion = preguntaDto.validacion || undefined;
|
||||
|
||||
const savedPregunta = await queryRunner.manager.save(pregunta);
|
||||
|
||||
@@ -165,7 +210,8 @@ export class CuestionarioService {
|
||||
async findCompleto(id: number) {
|
||||
// Obtener el cuestionario básico
|
||||
const cuestionario = await this.cuestionarioRepository.findOne({
|
||||
where: { id_cuestionario: id }
|
||||
where: { id_cuestionario: id },
|
||||
relations: ['evento'] // Incluir la relación con el evento
|
||||
});
|
||||
|
||||
if (!cuestionario) {
|
||||
@@ -179,6 +225,13 @@ export class CuestionarioService {
|
||||
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' }
|
||||
};
|
||||
|
||||
// Obtener las relaciones cuestionario-seccion
|
||||
const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find({
|
||||
where: { id_cuestionario: id },
|
||||
@@ -216,14 +269,8 @@ export class CuestionarioService {
|
||||
|
||||
if (!pregunta) return null;
|
||||
|
||||
// Usar un mapeo hardcodeado para tipos de pregunta
|
||||
const tiposPregunta = {
|
||||
1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
|
||||
2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
|
||||
3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
|
||||
};
|
||||
|
||||
const tipoPregunta = tiposPregunta[pregunta.id_tipo_pregunta] ||
|
||||
// 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' };
|
||||
|
||||
// Obtener opciones para preguntas de tipo multiple/radio
|
||||
@@ -255,6 +302,7 @@ export class CuestionarioService {
|
||||
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
|
||||
@@ -286,6 +334,13 @@ export class CuestionarioService {
|
||||
id_tipo_cuestionario: tipoCuestionario.id_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,
|
||||
cuestionario: {
|
||||
id_cuestionario: cuestionario.id_cuestionario,
|
||||
nombre_form: cuestionario.nombre_form,
|
||||
@@ -296,13 +351,43 @@ export class CuestionarioService {
|
||||
fecha_fin: cuestionario.fecha_fin ? cuestionario.fecha_fin.toISOString() : null,
|
||||
id_cuestionario_original: cuestionario.id_cuestionario_original,
|
||||
id_tipo_cuestionario: cuestionario.id_tipo_cuestionario,
|
||||
id_evento: cuestionario.id_evento || null,
|
||||
secciones: seccionesFormateadas
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
|
||||
return this.cuestionarioRepository.update(id, updateCuestionarioDto);
|
||||
async update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
|
||||
// Si el DTO incluye el campo "evento" (string), debemos procesarlo aparte
|
||||
if (updateCuestionarioDto.evento) {
|
||||
// Buscar o crear evento
|
||||
let evento = await this.eventoRepository.findOne({
|
||||
where: { nombre_evento: updateCuestionarioDto.evento }
|
||||
});
|
||||
|
||||
if (!evento) {
|
||||
// Crear el evento
|
||||
const nuevoEvento = {
|
||||
nombre_evento: updateCuestionarioDto.evento,
|
||||
tipo_evento: 'Predeterminado',
|
||||
fecha_inicio: new Date(),
|
||||
fecha_fin: new Date(Date.now() + 86400000)
|
||||
};
|
||||
|
||||
evento = await this.eventoRepository.save(nuevoEvento);
|
||||
}
|
||||
|
||||
// Asignar el ID del evento al DTO de actualización
|
||||
updateCuestionarioDto.id_evento = evento.id_evento;
|
||||
|
||||
// Eliminar la propiedad evento para evitar conflictos
|
||||
delete updateCuestionarioDto.evento;
|
||||
}
|
||||
|
||||
// Preparar los datos para la actualización
|
||||
const updateData: any = { ...updateCuestionarioDto };
|
||||
|
||||
return this.cuestionarioRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto';
|
||||
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateCuestionarioDto {
|
||||
@ApiProperty({
|
||||
description: 'Nombre del formulario',
|
||||
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
nombre_form: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Descripción del formulario',
|
||||
example: 'Formulario de registro para la feria...',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descripcion?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha de inicio',
|
||||
example: '2025-03-26T00:00:00',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fecha_inicio?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha de fin',
|
||||
example: '2025-03-31T23:59:59',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fecha_fin?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID del tipo de cuestionario',
|
||||
example: 1
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
id_tipo_cuestionario: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nombre del evento asociado',
|
||||
example: 'Feria de la Sexualidad',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
evento?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Secciones del cuestionario',
|
||||
type: [CreateSeccionDto],
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,94 +1,220 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TipoPreguntaEnum } from '../../pregunta/dto/create-pregunta.dto';
|
||||
|
||||
export class OpcionDto {
|
||||
@ApiProperty({
|
||||
description: 'Valor de la opción',
|
||||
example: 'Si'
|
||||
})
|
||||
valor: string;
|
||||
}
|
||||
|
||||
export class PreguntaDto {
|
||||
@ApiProperty({
|
||||
description: 'Título de la pregunta',
|
||||
example: '¿Eres parte de la comunidad de la FES Acatlán?'
|
||||
})
|
||||
titulo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Indica si la pregunta es obligatoria',
|
||||
example: true
|
||||
})
|
||||
obligatoria: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Tipo de pregunta (Texto, Numero, Multiple, Radio, Abierto, Fecha)',
|
||||
example: 'Multiple'
|
||||
})
|
||||
tipo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Lista de opciones para preguntas de tipo Multiple o Radio',
|
||||
type: [OpcionDto],
|
||||
required: false
|
||||
})
|
||||
opciones?: OpcionDto[];
|
||||
}
|
||||
|
||||
export class SeccionDto {
|
||||
@ApiProperty({
|
||||
description: 'Título de la sección',
|
||||
example: 'Información Personal'
|
||||
})
|
||||
titulo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Descripción de la sección',
|
||||
example: 'Proporciona tus datos personales para poder contactarte.'
|
||||
})
|
||||
descripcion?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Lista de preguntas que conforman la sección',
|
||||
type: [PreguntaDto]
|
||||
})
|
||||
preguntas: PreguntaDto[];
|
||||
}
|
||||
// Ejemplo de respuesta de cuestionario completo
|
||||
export const FormularioFeriaSchema = {
|
||||
tipo_cuestionario: {
|
||||
id_tipo_cuestionario: 1,
|
||||
tipo_cuestionario: 'Encuesta'
|
||||
},
|
||||
cuestionario: {
|
||||
id_cuestionario: 10,
|
||||
nombre_form: 'Registro para la Feria de la Sexualidad - Comunidad FES Acatlán',
|
||||
contador_secciones: 2,
|
||||
descripcion: 'La Feria de la Sexualidad es un espacio seguro e informativo dirigido a toda la comunidad universitaria. Este registro nos ayudará a organizar mejor las actividades, talleres y charlas, así como conocer tus intereses. Por favor, completa el siguiente formulario para confirmar tu participación.',
|
||||
editable: true,
|
||||
fecha_inicio: '2025-03-26T00:00:00',
|
||||
fecha_fin: '2025-03-31T23:59:59',
|
||||
id_cuestionario_original: null,
|
||||
id_tipo_cuestionario: 1,
|
||||
secciones: [
|
||||
{
|
||||
id_cuestionario_seccion: 1,
|
||||
posicion: 1,
|
||||
seccion: {
|
||||
id_seccion: 100,
|
||||
contador_pregunta: 3,
|
||||
descripcion: 'Información básica',
|
||||
titulo: 'Datos personales'
|
||||
},
|
||||
preguntas: [
|
||||
{
|
||||
id_seccion_pregunta: 1000,
|
||||
posicion: 1,
|
||||
pregunta: {
|
||||
id_pregunta: 101,
|
||||
pregunta: '¿Eres parte de la comunidad de la FES Acatlán?',
|
||||
contador_opcion: 2,
|
||||
obligatoria: true,
|
||||
id_tipo_pregunta: 1,
|
||||
id_opcion_dependiente: null,
|
||||
tipo_pregunta: {
|
||||
id_tipo: 1,
|
||||
tipo_pregunta: 'Cerrada'
|
||||
},
|
||||
opciones: [
|
||||
{
|
||||
id_pregunta_opcion: 50001,
|
||||
posicion: 1,
|
||||
id_opcion: 90001,
|
||||
opcion: {
|
||||
id_opcion: 90001,
|
||||
opcion: 'Sí'
|
||||
}
|
||||
},
|
||||
{
|
||||
id_pregunta_opcion: 50002,
|
||||
posicion: 2,
|
||||
id_opcion: 90002,
|
||||
opcion: {
|
||||
id_opcion: 90002,
|
||||
opcion: 'No'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id_seccion_pregunta: 1005,
|
||||
posicion: 2,
|
||||
pregunta: {
|
||||
id_pregunta: 109,
|
||||
pregunta: 'Correo electrónico',
|
||||
contador_opcion: 0,
|
||||
obligatoria: true,
|
||||
id_tipo_pregunta: 2,
|
||||
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: {
|
||||
id_tipo: 2,
|
||||
tipo_pregunta: 'Abierta'
|
||||
},
|
||||
opciones: []
|
||||
}
|
||||
},
|
||||
{
|
||||
id_seccion_pregunta: 1001,
|
||||
posicion: 3,
|
||||
pregunta: {
|
||||
id_pregunta: 102,
|
||||
pregunta: 'Número de cuenta (solo para estudiantes de la FES Acatlán)',
|
||||
contador_opcion: 0,
|
||||
obligatoria: false,
|
||||
id_tipo_pregunta: 2,
|
||||
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: {
|
||||
id_tipo: 2,
|
||||
tipo_pregunta: 'Abierta'
|
||||
},
|
||||
opciones: []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id_cuestionario_seccion: 2,
|
||||
posicion: 2,
|
||||
seccion: {
|
||||
id_seccion: 101,
|
||||
contador_pregunta: 2,
|
||||
descripcion: 'Información sobre tus intereses',
|
||||
titulo: 'Intereses'
|
||||
},
|
||||
preguntas: [
|
||||
{
|
||||
id_seccion_pregunta: 1003,
|
||||
posicion: 1,
|
||||
pregunta: {
|
||||
id_pregunta: 106,
|
||||
pregunta: '¿Qué temas te interesan para la Feria de la Sexualidad?',
|
||||
contador_opcion: 5,
|
||||
obligatoria: true,
|
||||
id_tipo_pregunta: 3,
|
||||
id_opcion_dependiente: null,
|
||||
tipo_pregunta: {
|
||||
id_tipo: 3,
|
||||
tipo_pregunta: 'Multiple'
|
||||
},
|
||||
opciones: [
|
||||
{
|
||||
id_pregunta_opcion: 50010,
|
||||
posicion: 1,
|
||||
id_opcion: 90010,
|
||||
opcion: {
|
||||
id_opcion: 90010,
|
||||
opcion: 'Educación sexual'
|
||||
}
|
||||
},
|
||||
{
|
||||
id_pregunta_opcion: 50011,
|
||||
posicion: 2,
|
||||
id_opcion: 90011,
|
||||
opcion: {
|
||||
id_opcion: 90011,
|
||||
opcion: 'Prevención de ITS'
|
||||
}
|
||||
},
|
||||
{
|
||||
id_pregunta_opcion: 50012,
|
||||
posicion: 3,
|
||||
id_opcion: 90012,
|
||||
opcion: {
|
||||
id_opcion: 90012,
|
||||
opcion: 'Salud reproductiva'
|
||||
}
|
||||
},
|
||||
{
|
||||
id_pregunta_opcion: 50013,
|
||||
posicion: 4,
|
||||
id_opcion: 90013,
|
||||
opcion: {
|
||||
id_opcion: 90013,
|
||||
opcion: 'Diversidad sexual y de género'
|
||||
}
|
||||
},
|
||||
{
|
||||
id_pregunta_opcion: 50014,
|
||||
posicion: 5,
|
||||
id_opcion: 90014,
|
||||
opcion: {
|
||||
id_opcion: 90014,
|
||||
opcion: 'Relaciones saludables'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id_seccion_pregunta: 1004,
|
||||
posicion: 2,
|
||||
pregunta: {
|
||||
id_pregunta: 107,
|
||||
pregunta: '¿Algún otro tema que te gustaría que se abordara?',
|
||||
contador_opcion: 0,
|
||||
obligatoria: false,
|
||||
id_tipo_pregunta: 2,
|
||||
id_opcion_dependiente: null,
|
||||
limite: 250, // Límite de caracteres para respuestas de texto
|
||||
tipo_pregunta: {
|
||||
id_tipo: 2,
|
||||
tipo_pregunta: 'Abierta'
|
||||
},
|
||||
opciones: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Ejemplo de tipo FormularioDto para usar en la API
|
||||
export class FormularioDto {
|
||||
@ApiProperty({
|
||||
description: 'Nombre del formulario',
|
||||
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
|
||||
description: 'Tipo de cuestionario',
|
||||
example: {
|
||||
id_tipo_cuestionario: 1,
|
||||
tipo_cuestionario: 'Encuesta'
|
||||
}
|
||||
})
|
||||
nombre_form: string;
|
||||
tipo_cuestionario: any;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Descripción del formulario',
|
||||
example: 'La Feria de la Sexualidad es un espacio seguro e informativo...'
|
||||
description: 'Información completa del cuestionario',
|
||||
example: FormularioFeriaSchema.cuestionario
|
||||
})
|
||||
descripcion?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha de inicio del formulario',
|
||||
example: '2025-03-07T00:00:01'
|
||||
})
|
||||
fecha_inicio?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha de fin del formulario',
|
||||
example: '2025-03-18T23:59:59'
|
||||
})
|
||||
fecha_fin?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID del tipo de cuestionario',
|
||||
example: 1
|
||||
})
|
||||
id_tipo_cuestionario: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Secciones que conforman el formulario',
|
||||
type: [SeccionDto]
|
||||
})
|
||||
secciones: SeccionDto[];
|
||||
cuestionario: any;
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCuestionarioDto } from './create-cuestionario.dto';
|
||||
import { IsNumber, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {}
|
||||
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {
|
||||
@ApiProperty({
|
||||
description: 'ID del evento asociado',
|
||||
example: 1,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
id_evento?: number;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity';
|
||||
import { Evento } from 'src/evento/evento.entity';
|
||||
import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne } from 'typeorm';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
|
||||
|
||||
|
||||
@@ -39,4 +40,11 @@ export class Cuestionario {
|
||||
|
||||
@ManyToOne(()=> Cuestionario)
|
||||
cuestionarioOriginal: Cuestionario;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
id_evento: number;
|
||||
|
||||
@ManyToOne(() => Evento)
|
||||
@JoinColumn({ name: 'id_evento' })
|
||||
evento: Evento;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Res } from '@nestjs/common';
|
||||
import { CuestionarioRespondidoService } from './cuestionario_respondido.service';
|
||||
import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
|
||||
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
|
||||
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
|
||||
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CuestionarioRespondidoApiDocumentation } from './cuestionario_respondido.documentation';
|
||||
import { Response } from 'express';
|
||||
|
||||
@ApiTags('Cuestionario Respondido')
|
||||
@Controller('cuestionario-respondido')
|
||||
export class CuestionarioRespondidoController {
|
||||
constructor(private readonly cuestionarioRespondidoService: CuestionarioRespondidoService) {}
|
||||
@@ -12,11 +17,36 @@ export class CuestionarioRespondidoController {
|
||||
return this.cuestionarioRespondidoService.create(createCuestionarioRespondidoDto);
|
||||
}
|
||||
|
||||
@Post('submit')
|
||||
@CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestas
|
||||
@ApiBody({
|
||||
type: SubmitRespuestasDto,
|
||||
examples: {
|
||||
comunidad: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.communityExample,
|
||||
externos: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.externalExample
|
||||
}
|
||||
})
|
||||
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse201
|
||||
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse400
|
||||
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse404
|
||||
submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) {
|
||||
return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.cuestionarioRespondidoService.findAll();
|
||||
}
|
||||
|
||||
@Get('/reporte-respuestas/:id')
|
||||
@CuestionarioRespondidoApiDocumentation.ApiReporteRespuestas
|
||||
async descargarReporteRespuestas(@Param('id') id: string, @Res() res: Response) {
|
||||
const reporte = await this.cuestionarioRespondidoService.generarReporteRespuestas(+id);
|
||||
res.header('Content-Type', 'text/csv');
|
||||
res.header('Content-Disposition', `attachment; filename="reporte-cuestionario-${id}.csv"`);
|
||||
return res.send(reporte);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.cuestionarioRespondidoService.findOne(+id);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
|
||||
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 ApiSubmitResponse201 = ApiResponse({
|
||||
status: 201,
|
||||
description: 'Respuestas guardadas correctamente',
|
||||
schema: {
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: 'Respuestas guardadas correctamente' },
|
||||
cuestionarioRespondidoId: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static ApiSubmitResponse400 = ApiResponse({
|
||||
status: 400,
|
||||
description: 'Datos inválidos en la solicitud',
|
||||
schema: {
|
||||
properties: {
|
||||
statusCode: { type: 'number', example: 400 },
|
||||
message: { type: 'string', example: 'La opción con ID 999 no está asociada a la pregunta 101' },
|
||||
error: { type: 'string', example: 'Bad Request' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static ApiSubmitResponse404 = ApiResponse({
|
||||
status: 404,
|
||||
description: 'Cuestionario o pregunta no encontrada',
|
||||
schema: {
|
||||
properties: {
|
||||
statusCode: { type: 'number', example: 404 },
|
||||
message: { type: 'string', example: 'Cuestionario con ID 999 no encontrado' },
|
||||
error: { type: 'string', example: 'Not Found' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static get ApiReporteRespuestas() {
|
||||
return applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Descargar reporte de respuestas de un cuestionario',
|
||||
description: `
|
||||
Este endpoint permite descargar un archivo CSV con todas las respuestas de un cuestionario.
|
||||
El reporte incluye información del participante, evento asociado, y el contenido de las respuestas.
|
||||
Se generará un archivo CSV con los siguientes campos:
|
||||
- ID Respuesta
|
||||
- Fecha Respuesta
|
||||
- ID Participante
|
||||
- Correo Participante
|
||||
- Evento
|
||||
- Cuestionario
|
||||
- Pregunta
|
||||
- Respuesta
|
||||
`
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Reporte de respuestas descargado exitosamente como archivo CSV'
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 404,
|
||||
description: 'Cuestionario no encontrado'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
static ApiSubmitRespuestasExamples = {
|
||||
communityExample: {
|
||||
summary: 'Ejemplo de respuesta para un miembro de la comunidad',
|
||||
value: {
|
||||
id_formulario: 1,
|
||||
correo: 'estudiante@acatlan.unam.mx',
|
||||
respuestas: [
|
||||
{ id_pregunta: 101, valor: 3 }, // Opción "Si" (usando el ID de la opción)
|
||||
{ id_pregunta: 102, valor: 'estudiante@acatlan.unam.mx' }, // Correo electrónico
|
||||
{ id_pregunta: 103, valor: '123456789' }, // Numero de cuenta
|
||||
{ id_pregunta: 106, valor: 7 } // Opción "Prefiero no decirlo" (usando el ID de la opción)
|
||||
],
|
||||
fecha_envio: '2025-04-02T13:45:00'
|
||||
},
|
||||
description: 'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.'
|
||||
},
|
||||
externalExample: {
|
||||
summary: 'Ejemplo de respuesta para un participante externo con selección múltiple',
|
||||
value: {
|
||||
id_formulario: 1,
|
||||
correo: 'externo@ejemplo.com',
|
||||
respuestas: [
|
||||
{ id_pregunta: 101, valor: 4 }, // Opción "No" (usando el ID de la opción)
|
||||
{ id_pregunta: 102, valor: 'externo@ejemplo.com' }, // Correo electrónico
|
||||
{ id_pregunta: 103, valor: 'Juan Carlos' }, // Nombre(s)
|
||||
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos
|
||||
{ id_pregunta: 105, valor: [2, 5, 8] }, // Selección múltiple (array de IDs de opciones)
|
||||
{ id_pregunta: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción)
|
||||
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' } // Institución de procedencia
|
||||
],
|
||||
fecha_envio: '2025-04-02T13:45:00'
|
||||
},
|
||||
description: 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.'
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,30 @@ 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 { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
|
||||
import { Pregunta } from '../pregunta/entities/pregunta.entity';
|
||||
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
|
||||
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
|
||||
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
|
||||
import { Opcion } from '../opcion/entities/opcion.entity';
|
||||
import { ValidacionesModule } from '../validaciones/validaciones.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CuestionarioRespondido]),
|
||||
TypeOrmModule.forFeature([
|
||||
CuestionarioRespondido,
|
||||
Participante,
|
||||
Cuestionario,
|
||||
Pregunta,
|
||||
RespuestaParticipanteAbierta,
|
||||
RespuestaParticipanteCerrada,
|
||||
PreguntaOpcion,
|
||||
Opcion
|
||||
]),
|
||||
CuestionarioModule,
|
||||
ParticipanteModule
|
||||
ParticipanteModule,
|
||||
ValidacionesModule
|
||||
],
|
||||
controllers: [CuestionarioRespondidoController],
|
||||
providers: [CuestionarioRespondidoService],
|
||||
|
||||
@@ -1,19 +1,384 @@
|
||||
import { Injectable } 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 { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
|
||||
import { Pregunta } from '../pregunta/entities/pregunta.entity';
|
||||
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
|
||||
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
|
||||
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
|
||||
import { Opcion } from '../opcion/entities/opcion.entity';
|
||||
import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service';
|
||||
import axios from 'axios';
|
||||
import * as QRCode from 'qrcode';
|
||||
@Injectable()
|
||||
export class CuestionarioRespondidoService {
|
||||
constructor(
|
||||
@InjectRepository(CuestionarioRespondido)
|
||||
private cuestionarioRespondidoRepository: Repository<CuestionarioRespondido>,
|
||||
@InjectRepository(Participante)
|
||||
private participanteRepository: Repository<Participante>,
|
||||
@InjectRepository(Cuestionario)
|
||||
private cuestionarioRepository: Repository<Cuestionario>,
|
||||
@InjectRepository(Pregunta)
|
||||
private preguntaRepository: Repository<Pregunta>,
|
||||
@InjectRepository(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,
|
||||
) {}
|
||||
|
||||
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
|
||||
return 'This action adds a new cuestionarioRespondido';
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return `This action returns all cuestionarioRespondido`;
|
||||
async submitRespuestas(submitDto: SubmitRespuestasDto) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 1. Verificar que el cuestionario existe
|
||||
const cuestionario = await this.cuestionarioRepository.findOne({
|
||||
where: { id_cuestionario: submitDto.id_formulario },
|
||||
relations: ['evento']
|
||||
});
|
||||
|
||||
if (!cuestionario) {
|
||||
throw new NotFoundException(`Cuestionario con ID ${submitDto.id_formulario} no encontrado`);
|
||||
}
|
||||
|
||||
// 2. Buscar o crear participante
|
||||
let participante = await this.participanteRepository.findOne({
|
||||
where: { correo: submitDto.correo }
|
||||
});
|
||||
|
||||
if (!participante) {
|
||||
participante = new Participante();
|
||||
participante.correo = submitDto.correo;
|
||||
participante.id_tipo_user = 1; // Usar un tipo de usuario por defecto
|
||||
participante = await queryRunner.manager.save(participante);
|
||||
}
|
||||
|
||||
// 3. Crear un nuevo registro de cuestionario respondido
|
||||
const cuestionarioRespondido = new CuestionarioRespondido();
|
||||
cuestionarioRespondido.cuestionario = cuestionario;
|
||||
cuestionarioRespondido.participante = participante;
|
||||
cuestionarioRespondido.fechaRespuesta = submitDto.fecha_envio ?
|
||||
new Date(submitDto.fecha_envio) : new Date();
|
||||
|
||||
const savedCuestionarioRespondido = await queryRunner.manager.save(cuestionarioRespondido);
|
||||
|
||||
// 4. Procesar cada respuesta
|
||||
for (const respuestaDto of submitDto.respuestas) {
|
||||
// Buscar la pregunta
|
||||
const pregunta = await this.preguntaRepository.findOne({
|
||||
where: { id_pregunta: respuestaDto.id_pregunta },
|
||||
relations: ['tipoPregunta']
|
||||
});
|
||||
|
||||
if (!pregunta) {
|
||||
throw new NotFoundException(`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`);
|
||||
}
|
||||
|
||||
// Si la pregunta tiene una validación configurada y es una respuesta abierta, validarla
|
||||
if (pregunta.validacion && typeof respuestaDto.valor === 'string') {
|
||||
const resultadoValidacion = this.validadorRespuestasService.validarRespuesta(
|
||||
respuestaDto.valor,
|
||||
pregunta.validacion
|
||||
);
|
||||
|
||||
if (!resultadoValidacion.valido) {
|
||||
throw new BadRequestException(`Validación fallida en pregunta ${pregunta.id_pregunta}: ${resultadoValidacion.mensaje}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determinar tipo de pregunta por ID
|
||||
const esPreguntaAbierta = pregunta.id_tipo_pregunta === 2;
|
||||
|
||||
// O determinar tipo de pregunta por su nombre si existe
|
||||
const esPreguntaTipoAbierta = pregunta.tipoPregunta &&
|
||||
pregunta.tipoPregunta.tipo_pregunta === 'Abierta';
|
||||
|
||||
// Determinar tipo de pregunta y guardar respuesta adecuadamente
|
||||
if (esPreguntaAbierta || esPreguntaTipoAbierta) {
|
||||
// Respuesta abierta (texto)
|
||||
// Convertir a string y truncar a 250 caracteres si es necesario
|
||||
let respuestaTexto = String(respuestaDto.valor);
|
||||
if (respuestaTexto.length > 250) {
|
||||
respuestaTexto = respuestaTexto.substring(0, 250);
|
||||
}
|
||||
|
||||
const respuestaAbierta = new RespuestaParticipanteAbierta();
|
||||
respuestaAbierta.pregunta = pregunta;
|
||||
respuestaAbierta.respuesta = respuestaTexto;
|
||||
respuestaAbierta.cuestionarioRespondido = savedCuestionarioRespondido;
|
||||
|
||||
await queryRunner.manager.save(respuestaAbierta);
|
||||
} else {
|
||||
// Respuesta cerrada (opción)
|
||||
// Verificar si es una respuesta múltiple (array de IDs)
|
||||
const opcionIds = Array.isArray(respuestaDto.valor)
|
||||
? respuestaDto.valor
|
||||
: [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();
|
||||
|
||||
if (preguntaOpciones.length === 0) {
|
||||
throw new BadRequestException(`La opción con ID ${opcionId} no está asociada a la pregunta ${respuestaDto.id_pregunta}`);
|
||||
}
|
||||
|
||||
// Guardar respuesta cerrada
|
||||
const respuestaCerrada = new RespuestaParticipanteCerrada();
|
||||
respuestaCerrada.preguntaOpcion = preguntaOpciones[0];
|
||||
respuestaCerrada.cuestionarioRespondido = savedCuestionarioRespondido;
|
||||
|
||||
await queryRunner.manager.save(respuestaCerrada);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variables para el QR y correo electrónico
|
||||
let datosQR: {
|
||||
id_participante: number;
|
||||
id_evento: number;
|
||||
} | null = null;
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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]
|
||||
);
|
||||
|
||||
// Si no existe, crear la relación
|
||||
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]
|
||||
);
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('Error al registrar participante en evento:', dbError.message);
|
||||
}
|
||||
|
||||
// Generar código QR
|
||||
const qrJsonData = JSON.stringify(datosQR);
|
||||
const qrBuffer = await QRCode.toBuffer(qrJsonData);
|
||||
const qrDataUrl = await QRCode.toDataURL(qrJsonData);
|
||||
|
||||
console.log('QR Data URL:', qrDataUrl);
|
||||
console.log("antes de enviar correo");
|
||||
|
||||
// Enviar correo con el QR
|
||||
const urlApiCorreos = process.env.url_api_correos || 'http://localhost:3000';
|
||||
|
||||
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'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
console.log(`Correo enviado exitosamente a ${participante.correo}`);
|
||||
} catch (error) {
|
||||
console.error('Error después de la transacción principal:', error);
|
||||
// No lanzamos el error para que no afecte la respuesta al usuario
|
||||
}
|
||||
}
|
||||
|
||||
// Preparar respuesta
|
||||
const response: any = {
|
||||
success: true,
|
||||
message: 'Respuestas guardadas correctamente',
|
||||
cuestionarioRespondidoId: savedCuestionarioRespondido.idCuestionarioRespondido
|
||||
};
|
||||
|
||||
// Incluir IDs para generar QR si hay evento asociado
|
||||
if (datosQR) {
|
||||
response.datos_qr = datosQR;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Solo hacemos rollback si no hemos hecho commit todavía
|
||||
try {
|
||||
await queryRunner.rollbackTransaction();
|
||||
} catch (rollbackError) {
|
||||
console.error('Error durante rollback:', rollbackError.message);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
findOne(id: number) {
|
||||
return `This action returns a #${id} cuestionarioRespondido`;
|
||||
async findAll() {
|
||||
// Obtener todos los cuestionarios respondidos con sus relaciones básicas
|
||||
const cuestionariosRespondidos = await this.cuestionarioRespondidoRepository.find({
|
||||
relations: ['participante', 'cuestionario', 'respuestasAbiertas', 'respuestasAbiertas.pregunta', 'respuestasCerradas']
|
||||
});
|
||||
|
||||
// Para cada cuestionario respondido, obtener y formatear las respuestas cerradas
|
||||
const resultados = await Promise.all(
|
||||
cuestionariosRespondidos.map(async (cuestionarioRespondido) => {
|
||||
// Obtener datos de respuestas cerradas con consulta SQL
|
||||
const respuestasCerradas = await this.dataSource.query(`
|
||||
SELECT
|
||||
rpc.idRespuestaParticipanteCerrada,
|
||||
rpc.id_pregunta_opcion,
|
||||
po.id_pregunta,
|
||||
p.pregunta,
|
||||
p.id_tipo_pregunta,
|
||||
o.id_opcion,
|
||||
o.opcion
|
||||
FROM respuesta_participante_cerrada rpc
|
||||
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
|
||||
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
|
||||
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
|
||||
WHERE rpc.id_cuestionario_respondido = ?
|
||||
`, [cuestionarioRespondido.idCuestionarioRespondido]);
|
||||
|
||||
// Formatear respuestas cerradas
|
||||
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
|
||||
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
|
||||
id_pregunta_opcion: respuesta.id_pregunta_opcion,
|
||||
pregunta: {
|
||||
id_pregunta: respuesta.id_pregunta,
|
||||
texto_pregunta: respuesta.pregunta,
|
||||
id_tipo_pregunta: respuesta.id_tipo_pregunta
|
||||
},
|
||||
opcion: {
|
||||
id_opcion: respuesta.id_opcion,
|
||||
opcion: respuesta.opcion
|
||||
}
|
||||
}));
|
||||
|
||||
// Devolver cuestionario con respuestas formateadas
|
||||
return {
|
||||
...cuestionarioRespondido,
|
||||
respuestasCerradas: respuestasCerradasFormateadas
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return resultados;
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
// Obtener cuestionario respondido con sus relaciones básicas
|
||||
const cuestionarioRespondido = await this.cuestionarioRespondidoRepository.findOne({
|
||||
where: { idCuestionarioRespondido: id },
|
||||
relations: [
|
||||
'participante',
|
||||
'cuestionario',
|
||||
'respuestasAbiertas',
|
||||
'respuestasAbiertas.pregunta',
|
||||
'respuestasCerradas'
|
||||
]
|
||||
});
|
||||
|
||||
if (!cuestionarioRespondido) {
|
||||
throw new NotFoundException(`Cuestionario respondido con ID ${id} no encontrado`);
|
||||
}
|
||||
|
||||
// Obtener directamente los datos de respuestas cerradas desde la base de datos
|
||||
// usando una consulta SQL directa
|
||||
const respuestasCerradas = await this.dataSource.query(`
|
||||
SELECT
|
||||
rpc.idRespuestaParticipanteCerrada,
|
||||
rpc.id_pregunta_opcion,
|
||||
po.id_pregunta,
|
||||
p.pregunta,
|
||||
p.id_tipo_pregunta,
|
||||
o.id_opcion,
|
||||
o.opcion
|
||||
FROM respuesta_participante_cerrada rpc
|
||||
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
|
||||
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
|
||||
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
|
||||
WHERE rpc.id_cuestionario_respondido = ?
|
||||
`, [id]);
|
||||
|
||||
console.log('Respuestas cerradas SQL:', JSON.stringify(respuestasCerradas, null, 2));
|
||||
|
||||
// Transformar las respuestas cerradas al formato deseado
|
||||
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
|
||||
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
|
||||
id_pregunta_opcion: respuesta.id_pregunta_opcion,
|
||||
pregunta: {
|
||||
id_pregunta: respuesta.id_pregunta,
|
||||
texto_pregunta: respuesta.pregunta,
|
||||
id_tipo_pregunta: respuesta.id_tipo_pregunta
|
||||
},
|
||||
opcion: {
|
||||
id_opcion: respuesta.id_opcion,
|
||||
opcion: respuesta.opcion
|
||||
}
|
||||
}));
|
||||
|
||||
// Crear el objeto de respuesta con el formato deseado
|
||||
const resultado = {
|
||||
...cuestionarioRespondido,
|
||||
respuestasCerradas: respuestasCerradasFormateadas
|
||||
};
|
||||
|
||||
return resultado;
|
||||
}
|
||||
|
||||
update(id: number, updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto) {
|
||||
@@ -23,4 +388,98 @@ export class CuestionarioRespondidoService {
|
||||
remove(id: number) {
|
||||
return `This action removes a #${id} cuestionarioRespondido`;
|
||||
}
|
||||
|
||||
async generarReporteRespuestas(idCuestionario: number): Promise<string> {
|
||||
// Buscar el cuestionario para validar que existe
|
||||
const cuestionario = await this.cuestionarioRepository.findOne({
|
||||
where: { id_cuestionario: idCuestionario },
|
||||
relations: ['evento'],
|
||||
});
|
||||
|
||||
if (!cuestionario) {
|
||||
throw new NotFoundException(`Cuestionario con ID ${idCuestionario} no encontrado`);
|
||||
}
|
||||
|
||||
// Obtener todas las respuestas para este cuestionario con sus relaciones
|
||||
const respuestas = await this.dataSource.query(`
|
||||
SELECT
|
||||
cr.id_cuestionario_respondido,
|
||||
cr.fecha_respuesta,
|
||||
p.id_participante,
|
||||
p.correo AS correo_participante,
|
||||
c.id_cuestionario,
|
||||
c.nombre_form AS nombre_cuestionario,
|
||||
e.id_evento,
|
||||
e.nombre_evento,
|
||||
preg.id_pregunta,
|
||||
preg.pregunta,
|
||||
-- Para respuestas abiertas
|
||||
rpa.respuesta AS respuesta_abierta,
|
||||
-- Para respuestas cerradas
|
||||
o.id_opcion,
|
||||
o.opcion AS respuesta_cerrada
|
||||
FROM cuestionario_respondido cr
|
||||
JOIN participante p ON cr.id_participante = p.id_participante
|
||||
JOIN cuestionario c ON cr.id_cuestionario = c.id_cuestionario
|
||||
LEFT JOIN evento e ON c.id_evento = e.id_evento
|
||||
-- Respuestas abiertas
|
||||
LEFT JOIN respuesta_participante_abierta rpa ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido
|
||||
LEFT JOIN pregunta preg_abierta ON rpa.id_pregunta = preg_abierta.id_pregunta
|
||||
-- Respuestas cerradas
|
||||
LEFT JOIN respuesta_participante_cerrada rpc ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido
|
||||
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
|
||||
LEFT JOIN pregunta preg ON po.id_pregunta = preg.id_pregunta
|
||||
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
|
||||
WHERE c.id_cuestionario = ?
|
||||
ORDER BY cr.id_cuestionario_respondido, preg.id_pregunta
|
||||
`, [idCuestionario]);
|
||||
|
||||
// Preparar los datos para el CSV
|
||||
const datosReporte: string[][] = [];
|
||||
|
||||
// Agregar encabezados
|
||||
const encabezados: string[] = [
|
||||
'ID Respuesta',
|
||||
'Fecha Respuesta',
|
||||
'ID Participante',
|
||||
'Correo Participante',
|
||||
'Evento',
|
||||
'Cuestionario',
|
||||
'Pregunta',
|
||||
'Respuesta'
|
||||
];
|
||||
|
||||
datosReporte.push(encabezados);
|
||||
|
||||
// Procesar los resultados para el CSV
|
||||
for (const fila of respuestas) {
|
||||
const respuesta = fila.respuesta_abierta || fila.respuesta_cerrada || '';
|
||||
|
||||
datosReporte.push([
|
||||
fila.id_cuestionario_respondido?.toString() || '',
|
||||
new Date(fila.fecha_respuesta).toLocaleString(),
|
||||
fila.id_participante?.toString() || '',
|
||||
fila.correo_participante || '',
|
||||
fila.nombre_evento || 'Sin evento',
|
||||
fila.nombre_cuestionario || '',
|
||||
fila.pregunta || '',
|
||||
respuesta
|
||||
]);
|
||||
}
|
||||
|
||||
// Generar CSV de forma manual con valores separados por comas y líneas con saltos de línea
|
||||
// Escapar valores que contengan comas para evitar problemas con el formato CSV
|
||||
const csvContent = datosReporte.map(row =>
|
||||
row.map(value => {
|
||||
// Si el valor contiene comas, comillas o saltos de línea, encerrarlo en comillas dobles
|
||||
// y escapar cualquier comilla doble dentro del valor
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}).join(',')
|
||||
).join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
ValidateIf,
|
||||
MaxLength
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
|
||||
export class RespuestaDto {
|
||||
@ApiProperty({
|
||||
description: 'ID de la pregunta respondida',
|
||||
example: 101
|
||||
})
|
||||
@IsNumber()
|
||||
id_pregunta: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Valor de la respuesta según tipo de pregunta',
|
||||
examples: [
|
||||
'Texto para pregunta abierta', // String para preguntas abiertas
|
||||
5, // Número para preguntas cerradas (ID de opción)
|
||||
[2, 7, 9] // Array para preguntas múltiples (IDs de opciones)
|
||||
],
|
||||
oneOf: [
|
||||
{ type: 'string', description: 'Texto para pregunta abierta (máximo 250 caracteres)' },
|
||||
{ type: 'number', description: 'ID de opción para pregunta cerrada' },
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
description: 'Array de IDs de opciones para pregunta multiple'
|
||||
}
|
||||
]
|
||||
})
|
||||
@ValidateIf(o => typeof o.valor === 'string')
|
||||
@MaxLength(250, { message: 'Las respuestas de texto no pueden exceder 250 caracteres' })
|
||||
valor: string | number | number[];
|
||||
}
|
||||
|
||||
export class SubmitRespuestasDto {
|
||||
@ApiProperty({
|
||||
description: 'ID del formulario a responder',
|
||||
example: 1
|
||||
})
|
||||
@IsNumber()
|
||||
id_formulario: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Correo electrónico del participante',
|
||||
example: 'usuario@ejemplo.com'
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' })
|
||||
correo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Array de respuestas a las preguntas',
|
||||
type: [RespuestaDto],
|
||||
examples: [
|
||||
{
|
||||
id_pregunta: 101,
|
||||
valor: 'Texto para pregunta abierta'
|
||||
},
|
||||
{
|
||||
id_pregunta: 102,
|
||||
valor: 5 // ID de la opción para pregunta cerrada simple
|
||||
},
|
||||
{
|
||||
id_pregunta: 103,
|
||||
valor: [2, 7, 9] // Array de IDs de opciones para pregunta de selección múltiple
|
||||
}
|
||||
]
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RespuestaDto)
|
||||
respuestas: RespuestaDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Fecha y hora del envío',
|
||||
example: '2025-04-02T13:45:00'
|
||||
})
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
fecha_envio?: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { Opcion } from 'src/opcion/entities/opcion.entity';
|
||||
|
||||
@Entity('cuestionario_respondido')
|
||||
export class CuestionarioRespondido {
|
||||
|
||||
@@ -18,6 +18,10 @@ 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('Cuestionarios')
|
||||
.addTag('Secciones')
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class CreateEventoDto {
|
||||
@IsString()
|
||||
tipo_evento: string;
|
||||
|
||||
@IsString()
|
||||
nombre_evento: string;
|
||||
|
||||
@IsString()
|
||||
fecha_inicio: Date;
|
||||
|
||||
@IsString()
|
||||
fecha_fin: Date;
|
||||
|
||||
//agregar el id del administrador
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Imagen } from "src/imagen/imagen.entity";
|
||||
import { Participante } from "src/participante/participante.entity";
|
||||
import { ParticipanteEvento } from "src/participante_evento/participante_evento.entity";
|
||||
import { Column, Entity, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
@@ -38,4 +39,6 @@ export class Evento {
|
||||
@OneToMany(() => ParticipanteEvento, participanteEvento => participanteEvento.evento)
|
||||
participanteEventos: ParticipanteEvento[];
|
||||
|
||||
@OneToMany(() => Imagen, (imagen) => imagen.evento)
|
||||
imagenes: Imagen[]
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, 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 { ImagenModule } from 'src/imagen/imagen.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Evento])],
|
||||
imports: [TypeOrmModule.forFeature([Evento]), forwardRef(() => ImagenModule)],
|
||||
controllers: [EventoController],
|
||||
providers: [EventoService],
|
||||
exports: [EventoService]
|
||||
exports: [EventoService, TypeOrmModule]
|
||||
})
|
||||
export class EventoModule {}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class EventoService {
|
||||
|
||||
getEventos() {
|
||||
return this.eventoRepository.find({
|
||||
relations: ['participantes']
|
||||
relations: ['participanteEventos', 'imagenes'] //participante
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class EventoService {
|
||||
where: {
|
||||
id_evento
|
||||
},
|
||||
relations: ['participantes']
|
||||
relations: ['participanteEventos', 'imagenes'],
|
||||
})
|
||||
|
||||
if (!eventoFound)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
|
||||
@ApiSchema({ name: 'CreateImagenRequest' })
|
||||
class CreateCatDto {}
|
||||
|
||||
|
||||
export class CreateImagenDto {
|
||||
/*
|
||||
url: string
|
||||
orden: number
|
||||
tipo: string
|
||||
|
||||
|
||||
evento: {
|
||||
id_evento: number
|
||||
} // si usas relaciones
|
||||
|
||||
|
||||
evento: number
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
url: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
orden: number;
|
||||
|
||||
@IsString()
|
||||
tipo: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt() // tiene que ser el id del evento
|
||||
evento: number;
|
||||
|
||||
// Campo extra para controlar lógica, no se guarda
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
type: Boolean
|
||||
})
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
force_orden?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Type } from "class-transformer"
|
||||
import { IsInt, IsOptional, IsString } from "class-validator"
|
||||
|
||||
export class UpdateImagenDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
url?: string
|
||||
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
orden?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tipo?: string
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, HttpException, HttpStatus, Param, ParseIntPipe, Patch, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { ImagenService, imageStorage } from './imagen.service';
|
||||
import { UpdateImagenDto } from './dto/update.imagen.dto';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { extname, join } from 'path';
|
||||
import { diskStorage } from 'multer';
|
||||
import { CreateImagenDto } from './dto/create-imagen.dto';
|
||||
import { existsSync } from 'fs';
|
||||
import { Response } from 'express';
|
||||
import { ImagenApiDocumentation } from './imagen.documentation'; // Importar la documentación
|
||||
|
||||
|
||||
@ImagenApiDocumentation.ApiController
|
||||
@Controller('imagen')
|
||||
export class ImagenController {
|
||||
constructor(private imagenService: ImagenService) {}
|
||||
|
||||
@Get()
|
||||
getImagenes() {
|
||||
return this.imagenService.getImagenes()
|
||||
}
|
||||
|
||||
@Get('evento/:id_evento')
|
||||
@ImagenApiDocumentation.ApiGetImagenEvento // Aplicando la documentación al método
|
||||
getImagenEvento(@Param('id_evento', ParseIntPipe) id_evento: number) {
|
||||
return this.imagenService.getImagenEvento(id_evento)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ImagenApiDocumentation.ApiGetOne
|
||||
getImagen(@Param('id', ParseIntPipe) id_imagen: number) {
|
||||
return this.imagenService.getImagen(id_imagen)
|
||||
}
|
||||
|
||||
@Get('visual/:id')
|
||||
@ImagenApiDocumentation.ApiGetVisual // Aplicando la documentación al método
|
||||
async getVisualImagen(
|
||||
@Param('id') id_imagen: number,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const url = await this.imagenService.getVisualImagen(id_imagen);
|
||||
|
||||
const rutaImagen = join(process.cwd(), url);
|
||||
if (!existsSync(rutaImagen)) {
|
||||
throw new HttpException('Imagen no encontrada', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return res.sendFile(rutaImagen);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ImagenApiDocumentation.ApiCreate
|
||||
@UseInterceptors(FileInterceptor('imagen', {
|
||||
storage: diskStorage({
|
||||
destination: './uploads',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = extname(file.originalname);
|
||||
cb(null, `${uniqueName}${extension}`);
|
||||
},
|
||||
}),
|
||||
}))
|
||||
async subirImagen(@UploadedFile() file: Express.Multer.File, @Body() imagen: CreateImagenDto,) {
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('No se ha subido ningún archivo');
|
||||
}
|
||||
|
||||
if (!imagen.evento) {
|
||||
throw new BadRequestException('Falta el id_evento en los datos');
|
||||
}
|
||||
|
||||
const url = `/uploads/${file.filename}`;
|
||||
|
||||
return this.imagenService.crearImagen({
|
||||
...imagen,
|
||||
url,
|
||||
evento: imagen.evento,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Delete(':id')
|
||||
@ImagenApiDocumentation.ApiRemove
|
||||
deleteImagen(@Param('id', ParseIntPipe) id_imagen: number) {
|
||||
return this.imagenService.deleteImagen(id_imagen)
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ImagenApiDocumentation.ApiUpdate
|
||||
@UseInterceptors(FileInterceptor('imagen', { storage: imageStorage })) // Interceptor para manejar la subida de archivo
|
||||
async updateImagen(
|
||||
@Param('id') id: number,
|
||||
@UploadedFile() file: Express.Multer.File, // Recibe el archivo cargado
|
||||
@Body() imagen: UpdateImagenDto,
|
||||
) {
|
||||
if (file) {
|
||||
// Si se sube un nuevo archivo, actualiza la URL
|
||||
imagen.url = `/uploads/${file.filename}`;
|
||||
}
|
||||
const newImagen = await this.imagenService.updateImagen(id, imagen);
|
||||
return newImagen;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { applyDecorators, Get, HttpException, HttpStatus, Param, ParseIntPipe, Res } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
|
||||
export class ImagenApiDocumentation {
|
||||
|
||||
// Decoradores para toda la clase del controlador
|
||||
static ApiController = ApiTags('Imágenes');
|
||||
// Documentación para obtener una imagen visual por ID
|
||||
static ApiGetVisual = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener imagen visual por ID',
|
||||
description: 'Retorna una imagen visual específica dada su ID.'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID de la imagen visual',
|
||||
required: true,
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Imagen visual encontrada correctamente.',
|
||||
content: {
|
||||
'image/png': {
|
||||
schema: { type: 'string', format: 'binary' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Imagen no encontrada.' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
|
||||
);
|
||||
|
||||
// Documentación para obtener las imágenes asociadas a un evento
|
||||
static ApiGetImagenEvento = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener imágenes por evento',
|
||||
description: 'Retorna una lista de imágenes asociadas a un evento específico.'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id_evento',
|
||||
description: 'ID del evento',
|
||||
required: true,
|
||||
type: 'number',
|
||||
example: 10
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Imágenes del evento obtenidas correctamente.',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_imagen: { type: 'number', example: 1 },
|
||||
url: { type: 'string', example: '/uploads/imagen1.png' },
|
||||
evento_id: { type: 'number', example: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Evento no encontrado.' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
// Documentación para crear una nueva imagen
|
||||
static ApiCreate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Crear una nueva imagen',
|
||||
description: 'Crea una nueva imagen asociada a un evento específico.'
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos para crear una nueva imagen',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['orden', 'tipo', 'evento'],
|
||||
properties: {
|
||||
url: { type: 'string', example: '/uploads/imagen1.png' },
|
||||
orden: { type: 'number', example: 1 },
|
||||
tipo: { type: 'string', example: 'cabezera' },
|
||||
evento: { type: 'number', example: 5 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 201,
|
||||
description: 'Imagen creada correctamente.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_imagen: { type: 'number', example: 1 },
|
||||
url: { type: 'string', example: '/uploads/imagen1.png' },
|
||||
orden: { type: 'number', example: 1 },
|
||||
tipo: { type: 'string', example: 'cabezera' },
|
||||
evento: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_evento: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos inválidos para crear la imagen.' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor.' })
|
||||
);
|
||||
|
||||
// Documentación para actualizar una imagen
|
||||
static ApiUpdate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Actualizar una imagen',
|
||||
description: 'Permite actualizar los datos de una imagen existente'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID de la imagen a actualizar',
|
||||
required: true,
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos a actualizar de la imagen',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', example: '/uploads/1745549896986-789608318_updated.png' },
|
||||
descripcion: { type: 'string', example: 'Nueva imagen de ejemplo para actualización' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Imagen actualizada correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
affected: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos inválidos para la actualización de la imagen.' }),
|
||||
ApiResponse({ status: 404, description: 'Imagen no encontrada.' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor' })
|
||||
);
|
||||
|
||||
// Documentación para obtener una imagen por ID
|
||||
static ApiGetOne = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener una imagen por ID',
|
||||
description: 'Permite obtener los detalles de una imagen a partir de su ID.'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID de la imagen',
|
||||
required: true,
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Imagen encontrada correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_imagen: { type: 'number', example: 1 },
|
||||
url: { type: 'string', example: '/uploads/1745549896986-789608318.png' },
|
||||
orden: { type: 'number', example: '1'},
|
||||
tipo: { type: 'string', example: 'Cabezera'},
|
||||
//descripcion: { type: 'string', example: 'Imagen de muestra para la documentación' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Imagen no encontrada' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor' })
|
||||
);
|
||||
|
||||
// Documentación para eliminar una imagen
|
||||
static ApiRemove = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Eliminar una imagen',
|
||||
description: 'Permite eliminar una imagen a partir de su ID.'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID de la imagen a eliminar',
|
||||
required: true,
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Imagen eliminada correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
affected: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Imagen no encontrada' }),
|
||||
ApiResponse({ status: 500, description: 'Error interno del servidor' })
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsInt } from "class-validator";
|
||||
import { Evento } from "src/evento/evento.entity";
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity()
|
||||
export class Imagen {
|
||||
@PrimaryGeneratedColumn()
|
||||
id_imagen: number
|
||||
|
||||
@Column()
|
||||
url?: string
|
||||
|
||||
@Column()
|
||||
@Type(() => Number)
|
||||
orden: number
|
||||
|
||||
@Column()
|
||||
tipo: string
|
||||
|
||||
@ManyToOne(() => Evento, (evento) => evento.imagenes, { onDelete: 'CASCADE' })
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
evento: Evento;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { ImagenService } from './imagen.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Imagen } from './imagen.entity';
|
||||
import { EventoModule } from 'src/evento/evento.module';
|
||||
import { ImagenController } from './imagen.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Imagen]), forwardRef(() => EventoModule)], //forwardRef(() => EventoModule
|
||||
controllers: [ImagenController],
|
||||
providers: [ImagenService],
|
||||
exports: [ImagenService]
|
||||
})
|
||||
export class ImagenModule {}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Imagen } from './imagen.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Evento } from 'src/evento/evento.entity';
|
||||
import { CreateImagenDto } from './dto/create-imagen.dto';
|
||||
import { UpdateImagenDto } from './dto/update.imagen.dto';
|
||||
import { extname } from 'path';
|
||||
import { diskStorage } from 'multer';
|
||||
|
||||
@Injectable()
|
||||
export class ImagenService {
|
||||
constructor(
|
||||
@InjectRepository(Imagen) private imagenRepository: Repository<Imagen>,
|
||||
@InjectRepository(Evento) private eventoRepository: Repository<Evento>
|
||||
) {}
|
||||
|
||||
async getUltimoOrden(id_evento: number): Promise<number> {
|
||||
const [ultimo] = await this.imagenRepository.find({
|
||||
where: {
|
||||
evento: { id_evento }, // Filtrar por evento
|
||||
},
|
||||
order: {
|
||||
orden: 'DESC',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
return ultimo?.orden ?? 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async crearImagen(imagen: CreateImagenDto): Promise<Imagen> {
|
||||
const evento = await this.eventoRepository.findOneBy({ id_evento: imagen.evento });
|
||||
|
||||
if (!evento) {
|
||||
throw new NotFoundException('Evento no encontrado');
|
||||
}
|
||||
|
||||
const ordenExiste = await this.imagenRepository.findOneBy({
|
||||
orden: imagen.orden,
|
||||
evento: { id_evento: imagen.evento },
|
||||
});
|
||||
|
||||
if (ordenExiste) {
|
||||
if (imagen.force_orden === false || imagen.force_orden === undefined) {
|
||||
throw new HttpException('Este orden ya existe', HttpStatus.CONFLICT)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (imagen.force_orden === true) {
|
||||
const imagenExistente = await this.imagenRepository.findOne({
|
||||
where: {
|
||||
orden: imagen.orden,
|
||||
evento: { id_evento: imagen.evento },
|
||||
},
|
||||
relations: ['evento'],
|
||||
});
|
||||
|
||||
if (imagenExistente) {
|
||||
const ultimoOrden = await this.getUltimoOrden(imagen.evento);
|
||||
imagenExistente.orden = ultimoOrden + 1;
|
||||
await this.imagenRepository.save(imagenExistente); // Guardar imagen existente con nuevo orden
|
||||
}
|
||||
}
|
||||
|
||||
// Crear y guardar nueva imagen
|
||||
const nuevaImagen = this.imagenRepository.create({
|
||||
...imagen,
|
||||
evento: { id_evento: imagen.evento }, // para la relación ManyToOne
|
||||
});
|
||||
|
||||
return this.imagenRepository.save(nuevaImagen);
|
||||
}
|
||||
|
||||
|
||||
getImagenes(): Promise<Imagen[]> {
|
||||
return this.imagenRepository.find()
|
||||
}
|
||||
|
||||
async getImagen(id_imagen: number) {
|
||||
const imagenFound = await this.imagenRepository.findOne({
|
||||
where: {
|
||||
id_imagen
|
||||
}
|
||||
})
|
||||
|
||||
if (!imagenFound) {
|
||||
return new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return imagenFound
|
||||
}
|
||||
|
||||
async getImagenEvento(id_evento: number) {
|
||||
const imagenEventoFound = await this.eventoRepository.findOne({
|
||||
where: {
|
||||
id_evento
|
||||
}
|
||||
})
|
||||
|
||||
if (!imagenEventoFound) {
|
||||
return new HttpException('Evento not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.imagenRepository.find({
|
||||
where: {
|
||||
evento: {
|
||||
id_evento
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getVisualImagen(id_imagen: number): Promise<string> {
|
||||
const imagenFound = await this.imagenRepository.findOne({
|
||||
where: {
|
||||
id_imagen
|
||||
},
|
||||
});
|
||||
|
||||
if (!imagenFound || !imagenFound.url) {
|
||||
throw new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return imagenFound.url;
|
||||
}
|
||||
|
||||
|
||||
async deleteImagen(id_imagen: number) {
|
||||
const result = await this.imagenRepository.delete({ id_imagen })
|
||||
|
||||
if (result.affected === 0) {
|
||||
return new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async updateImagen(id_imagen: number, imagen: UpdateImagenDto) {
|
||||
const imagenFound = await this.imagenRepository.findOne({
|
||||
where: {
|
||||
id_imagen
|
||||
}
|
||||
});
|
||||
|
||||
if (!imagenFound) {
|
||||
throw new HttpException('Imagen not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (imagen.url) {
|
||||
imagenFound.url = imagen.url;
|
||||
}
|
||||
|
||||
const updatedImagen = Object.assign(imagenFound, imagen);
|
||||
return this.imagenRepository.save(updatedImagen);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const imageStorage = diskStorage({
|
||||
destination: './uploads', // Carpeta donde se almacenarán las imágenes
|
||||
filename: (req, file, callback) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // Genera un nombre único
|
||||
callback(null, uniqueSuffix + extname(file.originalname)); // Usa la extensión original del archivo
|
||||
},
|
||||
});
|
||||
@@ -5,9 +5,14 @@ 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 { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
//const app = await NestFactory.create(AppModule);
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Configuración de validación global
|
||||
app.useGlobalPipes(
|
||||
@@ -15,6 +20,8 @@ async function bootstrap() {
|
||||
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
|
||||
|
||||
|
||||
})
|
||||
);
|
||||
|
||||
@@ -23,8 +30,19 @@ async function bootstrap() {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Verifica que la carpeta uploads esté correctamente ubicada
|
||||
const uploadsPath = resolve(__dirname, '..', 'uploads');
|
||||
|
||||
// Configurar los archivos estáticos para que se sirvan desde /uploads
|
||||
app.useStaticAssets(uploadsPath, {
|
||||
prefix: '/uploads', // Esto asegura que los archivos sean accesibles en http://localhost:4200/uploads
|
||||
});
|
||||
|
||||
|
||||
// Swagger setup
|
||||
DocsModule.setupSwagger(app);
|
||||
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { IsEmail } from "class-validator";
|
||||
import { IsEmail, IsNotEmpty, IsNumber } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class CreateParticipanteDto {
|
||||
@IsEmail()
|
||||
correo: string
|
||||
id_tipo_user: number
|
||||
@ApiProperty({
|
||||
description: 'Correo electrónico del participante',
|
||||
example: 'usuario@ejemplo.com',
|
||||
required: true
|
||||
})
|
||||
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
|
||||
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
|
||||
correo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID del tipo de usuario',
|
||||
example: 1,
|
||||
required: true
|
||||
})
|
||||
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
|
||||
@IsNotEmpty({ message: 'El ID del tipo de usuario es requerido' })
|
||||
id_tipo_user: number;
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
import { IsEmail, IsOptional, IsNumber } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class UpdateParticipanteDto {
|
||||
correo: string
|
||||
@ApiProperty({
|
||||
description: 'Nuevo correo electrónico del participante',
|
||||
example: 'nuevo_correo@ejemplo.com',
|
||||
required: false
|
||||
})
|
||||
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
|
||||
@IsOptional()
|
||||
correo?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nuevo ID del tipo de usuario',
|
||||
example: 2,
|
||||
required: false
|
||||
})
|
||||
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
|
||||
@IsOptional()
|
||||
id_tipo_user?: number;
|
||||
}
|
||||
@@ -3,58 +3,40 @@ import { Participante } from './participante.entity';
|
||||
import { ParticipanteService } from './participante.service';
|
||||
import { CreateParticipanteDto } from './dto/create-participante.dto';
|
||||
import { UpdateParticipanteDto } from './dto/update.participante.dto';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { ParticipanteApiDocumentation } from './participante.documentation';
|
||||
|
||||
@ApiTags('Participantes') // Agrupa los endpoints en Swagger
|
||||
@ParticipanteApiDocumentation.ApiController
|
||||
@Controller('participante')
|
||||
export class ParticipanteController {
|
||||
constructor(private participanteService: ParticipanteService) {}
|
||||
|
||||
@ApiOperation({ summary: 'Obtener todos los participantes' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de participantes obtenida correctamente.' })
|
||||
@ParticipanteApiDocumentation.ApiGetAll
|
||||
@Get()
|
||||
getParticipantes(): Promise<Participante[]> {
|
||||
return this.participanteService.getParticipantes()
|
||||
return this.participanteService.getParticipantes();
|
||||
}
|
||||
|
||||
@ParticipanteApiDocumentation.ApiGetOne
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Obtener un participante por ID' })
|
||||
@ApiParam({ name: 'id', description: 'ID del participante', example: 1 })
|
||||
@ApiResponse({ status: 200, description: 'Participante obtenido correctamente.' })
|
||||
@ApiResponse({ status: 404, description: 'Participante no encontrado.' })
|
||||
getParticipante(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.participanteService.getParticipante(id);
|
||||
}
|
||||
|
||||
@ParticipanteApiDocumentation.ApiCreate
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Registrar un nuevo participante' })
|
||||
@ApiBody({
|
||||
description: 'Datos del participante a registrar',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
correo: { type: 'string', example: 'user@example.com' },
|
||||
id_tipo_user: { type: 'integer', example: 2 }
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Participante registrado exitosamente.' })
|
||||
@ApiResponse({ status: 400, description: 'Datos inválidos.' })
|
||||
createParticipante(@Body() newParticipante: CreateParticipanteDto) {
|
||||
return this.participanteService.createParticipante(newParticipante);
|
||||
}
|
||||
|
||||
@ParticipanteApiDocumentation.ApiRemove
|
||||
@Delete(':id')
|
||||
deleteParticipante(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.participanteService.deleteParticipante(id)
|
||||
return this.participanteService.deleteParticipante(id);
|
||||
}
|
||||
|
||||
|
||||
@ParticipanteApiDocumentation.ApiUpdate
|
||||
@Patch(':id')
|
||||
updateParticipante(@Param('correo') id: number, @Body() participante: UpdateParticipanteDto) {
|
||||
updateParticipante(@Param('id', ParseIntPipe) id: number, @Body() participante: UpdateParticipanteDto) {
|
||||
return this.participanteService.updateParticipante(id, participante);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
|
||||
export class ParticipanteApiDocumentation {
|
||||
// Decorador para toda la clase del controlador
|
||||
static ApiController = ApiTags('Participantes');
|
||||
|
||||
// Documentación para crear un participante
|
||||
static ApiCreate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Registrar un nuevo participante',
|
||||
description: 'Crea un nuevo registro de participante en el sistema'
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos del participante a registrar',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['correo', 'id_tipo_user'],
|
||||
properties: {
|
||||
correo: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'usuario@ejemplo.com',
|
||||
description: 'Correo electrónico del participante'
|
||||
},
|
||||
id_tipo_user: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
description: 'ID del tipo de usuario'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 201,
|
||||
description: 'Participante registrado exitosamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'usuario@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos del participante inválidos' }),
|
||||
ApiResponse({ status: 409, description: 'El participante ya existe' })
|
||||
);
|
||||
|
||||
// Documentación para obtener todos los participantes
|
||||
static ApiGetAll = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener todos los participantes',
|
||||
description: 'Retorna una lista de todos los participantes registrados'
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Lista de participantes obtenida correctamente',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'usuario@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: { type: 'string', example: 'Estudiante' }
|
||||
}
|
||||
},
|
||||
participanteEventos: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante_evento: { type: 'number', example: 1 },
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
id_evento: { type: 'number', example: 1 },
|
||||
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
|
||||
estatus: { type: 'boolean', example: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Documentación para obtener un participante por ID
|
||||
static ApiGetOne = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Obtener un participante por ID',
|
||||
description: 'Retorna los datos de un participante específico según su ID'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del participante',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Participante obtenido correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'usuario@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_tipo_user: { type: 'number', example: 1 },
|
||||
tipo_user: { type: 'string', example: 'Estudiante' }
|
||||
}
|
||||
},
|
||||
participanteEventos: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante_evento: { type: 'number', example: 1 },
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
id_evento: { type: 'number', example: 1 },
|
||||
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
|
||||
estatus: { type: 'boolean', example: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Participante no encontrado' })
|
||||
);
|
||||
|
||||
// Documentación para actualizar un participante
|
||||
static ApiUpdate = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Actualizar datos de un participante',
|
||||
description: 'Actualiza la información de un participante existente'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del participante a actualizar',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiBody({
|
||||
description: 'Datos a actualizar del participante',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
correo: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'nuevo_correo@ejemplo.com',
|
||||
description: 'Nuevo correo electrónico del participante'
|
||||
},
|
||||
id_tipo_user: {
|
||||
type: 'integer',
|
||||
example: 2,
|
||||
description: 'Nuevo tipo de usuario'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Participante actualizado correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante: { type: 'number', example: 1 },
|
||||
correo: { type: 'string', example: 'nuevo_correo@ejemplo.com' },
|
||||
id_tipo_user: { type: 'number', example: 2 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
|
||||
ApiResponse({ status: 404, description: 'Participante no encontrado' })
|
||||
);
|
||||
|
||||
// Documentación para eliminar un participante
|
||||
static ApiRemove = applyDecorators(
|
||||
ApiOperation({
|
||||
summary: 'Eliminar un participante',
|
||||
description: 'Elimina permanentemente un participante por su ID'
|
||||
}),
|
||||
ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID del participante a eliminar',
|
||||
type: 'number',
|
||||
example: 1
|
||||
}),
|
||||
ApiResponse({
|
||||
status: 200,
|
||||
description: 'Participante eliminado correctamente',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
affected: { type: 'number', example: 1 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({ status: 404, description: 'Participante no encontrado' })
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,99 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Participante } from './participante.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateParticipanteDto } from './dto/create-participante.dto';
|
||||
//import { UpdateAdminDto } from 'src/admin/dto/update.admin.dto';
|
||||
import { UpdateParticipanteDto } from './dto/update.participante.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParticipanteService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
|
||||
) {}
|
||||
|
||||
async createParticipante(participante: CreateParticipanteDto) {
|
||||
/**
|
||||
* 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)
|
||||
return new HttpException('Participante already exists', HttpStatus.CONFLICT)
|
||||
if (participanteFound) {
|
||||
throw new ConflictException(`Ya existe un participante con el correo ${participante.correo}`);
|
||||
}
|
||||
|
||||
return this.participanteRepository.save(participante)
|
||||
const nuevoParticipante = this.participanteRepository.create(participante);
|
||||
return this.participanteRepository.save(nuevoParticipante);
|
||||
}
|
||||
|
||||
getParticipantes() {
|
||||
/**
|
||||
* Obtiene todos los participantes
|
||||
* @returns Lista de participantes con sus relaciones
|
||||
*/
|
||||
async getParticipantes(): Promise<Participante[]> {
|
||||
return this.participanteRepository.find({
|
||||
relations: ['tipo_user', 'participanteEventos']
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async getParticipante(id_participante: number) {
|
||||
/**
|
||||
* 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)
|
||||
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
|
||||
if (!participanteFound) {
|
||||
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
|
||||
}
|
||||
|
||||
return participanteFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un participante por su ID
|
||||
* @param id_participante ID del participante a eliminar
|
||||
* @returns Resultado de la eliminación
|
||||
* @throws NotFoundException si no se encuentra el participante
|
||||
*/
|
||||
async deleteParticipante(id_participante: number) {
|
||||
const result = await this.participanteRepository.delete({ id_participante })
|
||||
const result = await this.participanteRepository.delete({ id_participante });
|
||||
|
||||
if (result.affected === 0) {
|
||||
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
|
||||
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
success: true,
|
||||
message: `Participante con ID ${id_participante} eliminado correctamente`,
|
||||
affected: result.affected
|
||||
};
|
||||
}
|
||||
|
||||
async updateParticipante(id_participante: number, participante: UpdateParticipanteDto) {
|
||||
/**
|
||||
* 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
|
||||
@@ -64,11 +101,24 @@ export class ParticipanteService {
|
||||
});
|
||||
|
||||
if (!participanteFound) {
|
||||
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
|
||||
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
|
||||
}
|
||||
|
||||
const updateParticipante = Object.assign(participanteFound, participante)
|
||||
return this.participanteRepository.save(updateParticipante)
|
||||
}
|
||||
// Si se está actualizando el correo, verificar que no exista otro participante con ese correo
|
||||
if (participante.correo && participante.correo !== participanteFound.correo) {
|
||||
const existingParticipante = await this.participanteRepository.findOne({
|
||||
where: {
|
||||
correo: participante.correo
|
||||
}
|
||||
});
|
||||
|
||||
if (existingParticipante && existingParticipante.id_participante !== id_participante) {
|
||||
throw new ConflictException(`Ya existe otro participante con el correo ${participante.correo}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el participante
|
||||
const updateParticipante = Object.assign(participanteFound, participante);
|
||||
return this.participanteRepository.save(updateParticipante);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,32 +3,182 @@ import { ParticipanteEventoService } from './participante_evento.service';
|
||||
import { ParticipanteEvento } from './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';
|
||||
|
||||
@ApiTags('Participantes-Eventos')
|
||||
@Controller('participante-evento')
|
||||
export class ParticipanteEventoController {
|
||||
|
||||
constructor(private participanteEventoService: ParticipanteEventoService) {}
|
||||
|
||||
@ApiOperation({ summary: 'Obtener participantes por evento' })
|
||||
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Lista de participantes que están registrados en el evento',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante_evento: { type: 'number' },
|
||||
id_participante: { type: 'number' },
|
||||
id_evento: { type: 'number' },
|
||||
fecha_inscripcion: { type: 'string', format: 'date-time' },
|
||||
estatus: { type: 'boolean' },
|
||||
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
|
||||
participante: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante: { type: 'number' },
|
||||
correo: { type: 'string' },
|
||||
id_tipo_user: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Evento no encontrado' })
|
||||
@Get('evento/:idEvento')
|
||||
getParticipantesPorEvento(@Param('idEvento', ParseIntPipe) idEvento: number) {
|
||||
return this.participanteEventoService.getParticipantesPorEvento(idEvento);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Obtener eventos por participante' })
|
||||
@ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Lista de eventos en los que está registrado el participante',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_participante_evento: { type: 'number' },
|
||||
id_participante: { type: 'number' },
|
||||
id_evento: { type: 'number' },
|
||||
fecha_inscripcion: { type: 'string', format: 'date-time' },
|
||||
estatus: { type: 'boolean' },
|
||||
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
|
||||
evento: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id_evento: { type: 'number' },
|
||||
nombre_evento: { type: 'string' },
|
||||
tipo_evento: { type: 'string' },
|
||||
fecha_inicio: { type: 'string', format: 'date-time' },
|
||||
fecha_fin: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
|
||||
@Get('participante/:idParticipante')
|
||||
getEventosPorParticipante(@Param('idParticipante', ParseIntPipe) idParticipante: number) {
|
||||
return this.participanteEventoService.getEventosPorParticipante(idParticipante);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Obtener 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()
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.participanteEventoService.getParticipanteEvento(id)
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
@@ -15,6 +15,9 @@ export class ParticipanteEvento {
|
||||
@Column()
|
||||
estatus: boolean
|
||||
|
||||
@Column({ nullable: true, type: 'datetime' })
|
||||
fecha_asistencia: Date
|
||||
|
||||
/*
|
||||
@OneToOne(() => Qr, (qr) => qr.participanteEvento)
|
||||
qr: Qr;
|
||||
|
||||
@@ -53,6 +53,70 @@ export class ParticipanteEventoService {
|
||||
return participante_eventoFound
|
||||
}
|
||||
|
||||
async getParticipanteEventoEspecifico(id_participante: number, id_evento: number) {
|
||||
const participante_eventoFound = await this.participanteEventoRepository.findOne({
|
||||
where: {
|
||||
id_participante,
|
||||
id_evento
|
||||
},
|
||||
relations: ['evento', 'participante']
|
||||
})
|
||||
|
||||
if (!participante_eventoFound) {
|
||||
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return participante_eventoFound
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los participantes de un evento específico
|
||||
* @param id_evento ID del evento
|
||||
* @returns Lista de registros participante-evento con información del participante
|
||||
*/
|
||||
async getParticipantesPorEvento(id_evento: number) {
|
||||
// Verificar si el evento existe
|
||||
const eventoExists = await this.eventoRepository.findOne({
|
||||
where: { id_evento }
|
||||
});
|
||||
|
||||
if (!eventoExists) {
|
||||
throw new HttpException(`Evento con ID ${id_evento} no encontrado`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Obtener todos los registros participante-evento para este evento
|
||||
const participantesEvento = await this.participanteEventoRepository.find({
|
||||
where: { id_evento },
|
||||
relations: ['participante']
|
||||
});
|
||||
|
||||
return participantesEvento;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los eventos de un participante específico
|
||||
* @param id_participante ID del participante
|
||||
* @returns Lista de registros participante-evento con información del evento
|
||||
*/
|
||||
async getEventosPorParticipante(id_participante: number) {
|
||||
// Verificar si el participante existe
|
||||
const participanteExists = await this.participanteRepository.findOne({
|
||||
where: { id_participante }
|
||||
});
|
||||
|
||||
if (!participanteExists) {
|
||||
throw new HttpException(`Participante con ID ${id_participante} no encontrado`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Obtener todos los registros participante-evento para este participante
|
||||
const eventosParticipante = await this.participanteEventoRepository.find({
|
||||
where: { id_participante },
|
||||
relations: ['evento']
|
||||
});
|
||||
|
||||
return eventosParticipante;
|
||||
}
|
||||
|
||||
async deleteParticipanteEvento(id_participante: number) {
|
||||
const result = await this.participanteEventoRepository.delete({ id_participante })
|
||||
|
||||
@@ -78,4 +142,19 @@ export class ParticipanteEventoService {
|
||||
return this.participanteEventoRepository.save(participante_evento)
|
||||
}
|
||||
|
||||
async registrarAsistencia(id_participante: number, id_evento: number) {
|
||||
const participante_eventoFound = await this.participanteEventoRepository.findOne({
|
||||
where: {
|
||||
id_participante,
|
||||
id_evento
|
||||
}
|
||||
});
|
||||
|
||||
if (!participante_eventoFound) {
|
||||
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
participante_eventoFound.fecha_asistencia = new Date();
|
||||
return this.participanteEventoRepository.save(participante_eventoFound);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,112 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export enum TipoPreguntaEnum {
|
||||
CERRADA = 'Cerrada',
|
||||
ABIERTA = 'Abierta',
|
||||
MULTIPLE = 'Multiple'
|
||||
}
|
||||
|
||||
export class OpcionDto {
|
||||
@ApiProperty({
|
||||
description: 'Valor de la opción',
|
||||
example: 'Sí'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
valor: string;
|
||||
}
|
||||
|
||||
export class CreatePreguntaDto {
|
||||
@ApiProperty({
|
||||
description: 'Título o texto de la pregunta',
|
||||
example: '¿Eres parte de la comunidad de la FES Acatlán?'
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
titulo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Indica si la pregunta es obligatoria',
|
||||
example: true,
|
||||
default: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
obligatoria?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Límite de caracteres para respuestas de texto (solo aplica a preguntas tipo Abierta)',
|
||||
example: 250,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limite?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Tipo de pregunta',
|
||||
enum: Object.values(TipoPreguntaEnum),
|
||||
enumName: 'TipoPreguntaEnum',
|
||||
example: TipoPreguntaEnum.CERRADA,
|
||||
examples: {
|
||||
'cerrada': {
|
||||
summary: 'Pregunta de opción única',
|
||||
value: TipoPreguntaEnum.CERRADA
|
||||
},
|
||||
'abierta': {
|
||||
summary: 'Pregunta de texto libre',
|
||||
value: TipoPreguntaEnum.ABIERTA
|
||||
},
|
||||
'multiple': {
|
||||
summary: 'Pregunta de selección múltiple',
|
||||
value: TipoPreguntaEnum.MULTIPLE
|
||||
}
|
||||
}
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsEnum(TipoPreguntaEnum, {
|
||||
message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple'
|
||||
})
|
||||
tipo: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Contador de opciones (se calcula automáticamente)',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@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,
|
||||
examples: ['cuenta_alumno', 'correo', 'telefono', 'nombre', 'entero', 'decimal'],
|
||||
example: 'correo'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
validacion?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple',
|
||||
type: [OpcionDto],
|
||||
required: false,
|
||||
examples: [
|
||||
{ valor: 'Sí' },
|
||||
{ valor: 'No' }
|
||||
]
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -28,6 +28,9 @@ export class Pregunta {
|
||||
@Column({ type: 'int', nullable: true })
|
||||
id_opcion_dependiente?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
validacion?: string;
|
||||
|
||||
@OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta)
|
||||
seccionPreguntas: SeccionPregunta[];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { TipoPreguntaEnum } from './dto/create-pregunta.dto';
|
||||
|
||||
export class PreguntaApiDocumentation {
|
||||
// Decoradores para toda la clase del controlador
|
||||
@@ -19,9 +20,19 @@ export class PreguntaApiDocumentation {
|
||||
properties: {
|
||||
titulo: { type: 'string', example: '¿Eres parte de la comunidad?' },
|
||||
obligatoria: { type: 'boolean', example: true },
|
||||
tipo: { type: 'string', example: 'Multiple' },
|
||||
tipo: {
|
||||
type: 'string',
|
||||
enum: Object.values(TipoPreguntaEnum),
|
||||
description: 'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)',
|
||||
example: TipoPreguntaEnum.CERRADA
|
||||
},
|
||||
contador_opcion: { type: 'number', example: 0 },
|
||||
id_opcion_dependiente: { type: 'number', example: null },
|
||||
validacion: {
|
||||
type: 'string',
|
||||
description: 'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)',
|
||||
example: 'correo'
|
||||
},
|
||||
opciones: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@@ -32,6 +43,44 @@ export class PreguntaApiDocumentation {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
examples: {
|
||||
cerrada: {
|
||||
summary: 'Pregunta de opción única',
|
||||
value: {
|
||||
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
|
||||
obligatoria: true,
|
||||
tipo: TipoPreguntaEnum.CERRADA,
|
||||
validacion: null,
|
||||
opciones: [
|
||||
{ valor: 'Sí' },
|
||||
{ valor: 'No' }
|
||||
]
|
||||
}
|
||||
},
|
||||
abierta: {
|
||||
summary: 'Pregunta de texto libre',
|
||||
value: {
|
||||
titulo: '¿Qué mejorarías en nuestro servicio?',
|
||||
obligatoria: false,
|
||||
tipo: TipoPreguntaEnum.ABIERTA,
|
||||
validacion: null
|
||||
}
|
||||
},
|
||||
multiple: {
|
||||
summary: 'Pregunta de selección múltiple',
|
||||
value: {
|
||||
titulo: '¿Qué aspectos del servicio fueron de tu agrado?',
|
||||
obligatoria: false,
|
||||
tipo: TipoPreguntaEnum.MULTIPLE,
|
||||
validacion: null,
|
||||
opciones: [
|
||||
{ valor: 'Rapidez' },
|
||||
{ valor: 'Atención al cliente' },
|
||||
{ valor: 'Facilidad de uso' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ApiResponse({
|
||||
@@ -45,7 +94,8 @@ 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 }
|
||||
id_opcion_dependiente: { type: 'number', example: null },
|
||||
validacion: { type: 'string', example: 'correo' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -72,7 +122,8 @@ 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 }
|
||||
id_opcion_dependiente: { type: 'number', example: null },
|
||||
validacion: { type: 'string', example: 'correo' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +156,7 @@ export class PreguntaApiDocumentation {
|
||||
contador_opcion: { type: 'number', example: 0 },
|
||||
id_tipo_pregunta: { type: 'number', example: 1 },
|
||||
id_opcion_dependiente: { type: 'number', example: null },
|
||||
validacion: { type: 'string', example: 'correo' },
|
||||
opciones: {
|
||||
type: 'array',
|
||||
items: {
|
||||
|
||||
@@ -44,6 +44,7 @@ export class PreguntaService {
|
||||
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
|
||||
pregunta.id_opcion_dependiente = createPreguntaDto.id_opcion_dependiente || undefined;
|
||||
pregunta.contador_opcion = createPreguntaDto.opciones?.length || 0;
|
||||
pregunta.validacion = createPreguntaDto.validacion || undefined;
|
||||
|
||||
const savedPregunta = await queryRunner.manager.save(pregunta);
|
||||
|
||||
@@ -158,6 +159,7 @@ export class PreguntaService {
|
||||
if (updatePreguntaDto.titulo) pregunta.pregunta = updatePreguntaDto.titulo;
|
||||
if (updatePreguntaDto.obligatoria !== undefined) pregunta.obligatoria = updatePreguntaDto.obligatoria;
|
||||
if (updatePreguntaDto.id_opcion_dependiente !== undefined) pregunta.id_opcion_dependiente = updatePreguntaDto.id_opcion_dependiente;
|
||||
if (updatePreguntaDto.validacion !== undefined) pregunta.validacion = updatePreguntaDto.validacion;
|
||||
|
||||
// Si se proporciona un nuevo tipo de pregunta, actualizarlo
|
||||
if (updatePreguntaDto.tipo) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Opcion } from 'src/opcion/entities/opcion.entity';
|
||||
import { Pregunta } from 'src/pregunta/entities/pregunta.entity';
|
||||
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||
|
||||
|
||||
@Entity()
|
||||
@@ -13,13 +13,18 @@ export class PreguntaOpcion {
|
||||
posicion: number;
|
||||
|
||||
@ManyToOne(() => Pregunta)
|
||||
@JoinColumn({ name: 'id_pregunta' })
|
||||
pregunta: Pregunta;
|
||||
|
||||
@Column()
|
||||
id_pregunta: number;
|
||||
|
||||
@ManyToOne(() => Opcion, opcion => opcion.preguntasOpciones)
|
||||
@JoinColumn({ name: 'id_opcion' })
|
||||
opcion: Opcion;
|
||||
|
||||
@Column()
|
||||
id_opcion: number;
|
||||
|
||||
@OneToMany(()=> RespuestaParticipanteCerrada, respuestaParticipanteCerrada=>respuestaParticipanteCerrada.preguntaOpcion)
|
||||
respuestaParticipanteCerrada:RespuestaParticipanteCerrada[];
|
||||
|
||||
@@ -13,7 +13,7 @@ import { QrService } from './qr.service';
|
||||
import { Qr } from './qr.entity';
|
||||
import { CreateQrDto } from './dto/create-qr.dto';
|
||||
import { UpdateQrDto } from './dto/update.qr.dto';
|
||||
import { ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
|
||||
@Controller('qr')
|
||||
export class QrController {
|
||||
@@ -30,6 +30,17 @@ export class QrController {
|
||||
return this.qrService.generateQRCode(text);
|
||||
}
|
||||
|
||||
@Get('asistencia/:idParticipante/:idEvento')
|
||||
@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
|
||||
): Promise<string> {
|
||||
return this.qrService.generateAsistenciaQR(idParticipante, idEvento);
|
||||
}
|
||||
|
||||
@Get()
|
||||
getQrs(): Promise<Qr[]> {
|
||||
return this.qrService.getQrs();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateQrDto } from './dto/create-qr.dto';
|
||||
import { UpdateQrDto } from './dto/update.qr.dto';
|
||||
// @ts-ignore
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,6 +15,20 @@ export class QrService {
|
||||
return await QRCode.toDataURL(text);
|
||||
}
|
||||
|
||||
async generateAsistenciaQR(id_participante: number, id_evento: number): Promise<string> {
|
||||
// Crear un objeto JSON que contenga los IDs
|
||||
const qrData = {
|
||||
id_participante,
|
||||
id_evento
|
||||
};
|
||||
|
||||
// Convertir el objeto a una cadena JSON
|
||||
const jsonStr = JSON.stringify(qrData);
|
||||
|
||||
// Generar el código QR con la cadena JSON
|
||||
return await QRCode.toDataURL(jsonStr);
|
||||
}
|
||||
|
||||
async generateBuffer(text: string): Promise<Buffer> {
|
||||
return await QRCode.toBuffer(text); // Devuelve un buffer para adjuntar en email o stream
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ import { TipoPregunta } from './entities/tipo_pregunta.entity';
|
||||
imports: [TypeOrmModule.forFeature([TipoPregunta])],
|
||||
controllers: [TipoPreguntaController],
|
||||
providers: [TipoPreguntaService],
|
||||
exports: [TypeOrmModule]
|
||||
exports: [TipoPreguntaService, TypeOrmModule]
|
||||
})
|
||||
export class TipoPreguntaModule {}
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateTipoPreguntaDto } from './dto/create-tipo_pregunta.dto';
|
||||
import { UpdateTipoPreguntaDto } from './dto/update-tipo_pregunta.dto';
|
||||
import { TipoPregunta } from './entities/tipo_pregunta.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TipoPreguntaService {
|
||||
// Métodos del servicio
|
||||
export class TipoPreguntaService implements OnModuleInit {
|
||||
constructor(
|
||||
@InjectRepository(TipoPregunta)
|
||||
private tipoPreguntaRepository: Repository<TipoPregunta>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.seedTipoPreguntas();
|
||||
}
|
||||
|
||||
private async seedTipoPreguntas() {
|
||||
const defaultTipos = [
|
||||
{ tipo_pregunta: 'Cerrada' },
|
||||
{ tipo_pregunta: 'Abierta' },
|
||||
{ tipo_pregunta: 'Multiple' },
|
||||
{ tipo_pregunta: 'Texto' }
|
||||
];
|
||||
|
||||
for (const tipo of defaultTipos) {
|
||||
const existingTipo = await this.tipoPreguntaRepository.findOne({
|
||||
where: { tipo_pregunta: tipo.tipo_pregunta },
|
||||
});
|
||||
|
||||
if (!existingTipo) {
|
||||
await this.tipoPreguntaRepository.save(tipo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create(createTipoPreguntaDto: CreateTipoPreguntaDto) {
|
||||
return this.tipoPreguntaRepository.save(createTipoPreguntaDto);
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.tipoPreguntaRepository.find();
|
||||
}
|
||||
|
||||
findOne(id: number) {
|
||||
return this.tipoPreguntaRepository.findOne({
|
||||
where: { id_tipo: id }
|
||||
});
|
||||
}
|
||||
|
||||
update(id: number, updateTipoPreguntaDto: UpdateTipoPreguntaDto) {
|
||||
return this.tipoPreguntaRepository.update(id, updateTipoPreguntaDto);
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
return this.tipoPreguntaRepository.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ValidadorRespuestasService } from './validador-respuestas.service';
|
||||
|
||||
@Module({
|
||||
providers: [ValidadorRespuestasService],
|
||||
exports: [ValidadorRespuestasService]
|
||||
})
|
||||
export class ValidacionesModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { validarRespuesta, ResultadoValidacion } from './validador';
|
||||
|
||||
/**
|
||||
* Servicio para validar respuestas de cuestionarios
|
||||
* Utiliza los validadores específicos según el tipo de validación requerido
|
||||
*/
|
||||
@Injectable()
|
||||
export class ValidadorRespuestasService {
|
||||
/**
|
||||
* Valida una respuesta de acuerdo al tipo de validación de la pregunta
|
||||
* @param respuesta Texto de la respuesta a validar
|
||||
* @param tipoValidacion Tipo de validación ('correo', 'telefono', 'nombre', etc.)
|
||||
* @returns Resultado de la validación (válido y mensaje)
|
||||
*/
|
||||
validarRespuesta(respuesta: string, tipoValidacion: string): ResultadoValidacion {
|
||||
// Si no hay tipo de validación, la respuesta se considera válida
|
||||
if (!tipoValidacion) {
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'No se requiere validación específica'
|
||||
};
|
||||
}
|
||||
|
||||
return validarRespuesta(respuesta, tipoValidacion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida múltiples respuestas basadas en sus tipos de validación
|
||||
* @param respuestas Array de respuestas a validar
|
||||
* @param tiposValidacion Array de tipos de validación correspondientes
|
||||
* @returns Object con resultados de validación usando las posiciones como claves
|
||||
*/
|
||||
validarRespuestas(
|
||||
respuestas: string[],
|
||||
tiposValidacion: string[]
|
||||
): { [key: number]: ResultadoValidacion } {
|
||||
const resultados: { [key: number]: ResultadoValidacion } = {};
|
||||
|
||||
respuestas.forEach((respuesta, index) => {
|
||||
if (index < tiposValidacion.length) {
|
||||
resultados[index] = this.validarRespuesta(respuesta, tiposValidacion[index]);
|
||||
} else {
|
||||
// Si no hay un tipo de validación correspondiente, se asume válida
|
||||
resultados[index] = {
|
||||
valido: true,
|
||||
mensaje: 'No se requiere validación específica'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return resultados;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si todas las respuestas son válidas
|
||||
* @param resultados Resultados de validación
|
||||
* @returns true si todas las validaciones son correctas, false en caso contrario
|
||||
*/
|
||||
todasValidas(resultados: { [key: number]: ResultadoValidacion }): boolean {
|
||||
return Object.values(resultados).every(resultado => resultado.valido);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Módulo de validación para respuestas de cuestionarios
|
||||
* Este módulo puede ser utilizado tanto en backend (NestJS) como en frontend (Angular, React, etc.)
|
||||
*/
|
||||
|
||||
// Interfaz para el resultado de validación
|
||||
export interface ResultadoValidacion {
|
||||
valido: boolean;
|
||||
mensaje: string;
|
||||
}
|
||||
|
||||
// Clase base para todos los validadores
|
||||
export abstract class Validador {
|
||||
abstract validar(valor: string): ResultadoValidacion;
|
||||
}
|
||||
|
||||
// Clase para validar correos electrónicos
|
||||
export class ValidadorCorreo extends Validador {
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El correo electrónico es requerido'
|
||||
};
|
||||
}
|
||||
|
||||
// Expresión regular para validar correos
|
||||
// Valida el formato básico de correos: usuario@dominio.extensión
|
||||
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
if (!regex.test(valor)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El formato del correo electrónico no es válido'
|
||||
};
|
||||
}
|
||||
|
||||
// Para correos institucionales (opcional)
|
||||
if (this.validarDominioInstitucional) {
|
||||
const dominios = ['acatlan.unam.mx', 'unam.mx', 'comunidad.unam.mx'];
|
||||
const dominio = valor.split('@')[1];
|
||||
|
||||
if (!dominios.includes(dominio)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'Debe ser un correo institucional (@acatlan.unam.mx, @unam.mx, @comunidad.unam.mx)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Correo electrónico válido'
|
||||
};
|
||||
}
|
||||
|
||||
// Propiedad que permite configurar si se validan sólo correos institucionales
|
||||
constructor(public validarDominioInstitucional: boolean = false) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
// Clase para validar números telefónicos
|
||||
export class ValidadorTelefono extends Validador {
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El número telefónico es requerido'
|
||||
};
|
||||
}
|
||||
|
||||
// Eliminar espacios, guiones y paréntesis para la validación
|
||||
const numeroLimpio = valor.replace(/[\s\-()]/g, '');
|
||||
|
||||
// Verificar que solo contenga dígitos
|
||||
if (!/^\d+$/.test(numeroLimpio)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El número telefónico solo debe contener dígitos'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar longitud (para México, 10 dígitos)
|
||||
if (numeroLimpio.length !== 10) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El número telefónico debe tener 10 dígitos'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Número telefónico válido'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clase para validar nombres
|
||||
export class ValidadorNombre extends Validador {
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
const regex = new RegExp(
|
||||
'/^[A-Za-zÁÉÍÓÚáéíóúÑñ]+(?: [A-Za-zÁÉÍÓÚáéíóúÑñ]+)*$/'
|
||||
);
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El nombre es requerido'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar longitud mínima
|
||||
if (valor.trim().length < 2) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El nombre debe tener al menos 2 caracteres'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar que solo contenga letras y espacios
|
||||
if (regex.test(valor)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El nombre solo debe contener letras y espacios'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Nombre válido'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clase para validar números enteros
|
||||
export class ValidadorEntero extends Validador {
|
||||
constructor(
|
||||
private min: number = Number.MIN_SAFE_INTEGER,
|
||||
private max: number = Number.MAX_SAFE_INTEGER
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El valor numérico es requerido'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar que sea un número entero
|
||||
if (!/^-?\d+$/.test(valor)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El valor debe ser un número entero'
|
||||
};
|
||||
}
|
||||
|
||||
const numero = parseInt(valor, 10);
|
||||
|
||||
// Verificar rango
|
||||
if (numero < this.min || numero > this.max) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Número entero válido'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clase para validar números decimales
|
||||
export class ValidadorDecimal extends Validador {
|
||||
constructor(
|
||||
private min: number = Number.MIN_SAFE_INTEGER,
|
||||
private max: number = Number.MAX_SAFE_INTEGER,
|
||||
private decimales: number = 2
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El valor numérico es requerido'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar que sea un número decimal válido
|
||||
if (!/^-?\d+(\.\d+)?$/.test(valor)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'El valor debe ser un número decimal válido'
|
||||
};
|
||||
}
|
||||
|
||||
const numero = parseFloat(valor);
|
||||
|
||||
// Verificar rango
|
||||
if (numero < this.min || numero > this.max) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar precisión decimal
|
||||
const partes = valor.split('.');
|
||||
if (partes.length > 1 && partes[1].length > this.decimales) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: `El valor debe tener máximo ${this.decimales} decimales`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Número decimal válido'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clase para validar cuentas de alumno (formato específico UNAM - 9 dígitos)
|
||||
export class ValidadorCuentaAlumno extends Validador {
|
||||
validar(valor: string): ResultadoValidacion {
|
||||
if (!valor) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'La cuenta de alumno es requerida'
|
||||
};
|
||||
}
|
||||
|
||||
// Formato de cuenta UNAM: 9 dígitos para todos los alumnos
|
||||
if (!/^\d{9}$/.test(valor)) {
|
||||
return {
|
||||
valido: false,
|
||||
mensaje: 'La cuenta de alumno debe tener exactamente 9 dígitos numéricos'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valido: true,
|
||||
mensaje: 'Cuenta de alumno válida'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fábrica de validadores para crear el validador apropiado según el tipo
|
||||
export class FabricaValidadores {
|
||||
static crear(tipo: string): Validador | null {
|
||||
switch (tipo?.toLowerCase()) {
|
||||
case 'correo':
|
||||
return new ValidadorCorreo();
|
||||
case 'correo_institucional':
|
||||
return new ValidadorCorreo(true);
|
||||
case 'telefono':
|
||||
return new ValidadorTelefono();
|
||||
case 'nombre':
|
||||
return new ValidadorNombre();
|
||||
case 'entero':
|
||||
return new ValidadorEntero();
|
||||
case 'decimal':
|
||||
return new ValidadorDecimal();
|
||||
case 'cuenta_alumno':
|
||||
return new ValidadorCuentaAlumno();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Función auxiliar para validar respuestas basada en el tipo de validación
|
||||
export function validarRespuesta(respuesta: string, tipoValidacion: string): ResultadoValidacion {
|
||||
const validador = FabricaValidadores.crear(tipoValidacion);
|
||||
|
||||
if (!validador) {
|
||||
return {
|
||||
valido: true, // Si no hay validador, consideramos válida la respuesta
|
||||
mensaje: 'No se requiere validación específica'
|
||||
};
|
||||
}
|
||||
|
||||
return validador.validar(respuesta);
|
||||
}
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |