base con autenticacion, registro, modulo encuestas

This commit is contained in:
2025-06-16 12:02:22 -04:00
commit 475e0754df
411 changed files with 26265 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import 'dotenv/config';
import * as joi from 'joi';
interface EnvVars {
HOST: string;
ALLOW_CORS_URL: string;
PORT: number;
NODE_ENV: string;
DATABASE_URL: string;
ACCESS_TOKEN_SECRET: string;
ACCESS_TOKEN_EXPIRATION: string;
REFRESH_TOKEN_SECRET: string;
REFRESH_TOKEN_EXPIRATION: string;
MAIL_HOST: string;
MAIL_USERNAME: string;
MAIL_PASSWORD: string;
}
const envsSchema = joi
.object({
HOST: joi.string().required(),
ALLOW_CORS_URL: joi.string().required(),
PORT: joi.number().required(),
NODE_ENV: joi.string().required(),
DATABASE_URL: joi.string().required(),
ACCESS_TOKEN_SECRET: joi.string().required(),
ACCESS_TOKEN_EXPIRATION: joi.string().required(),
REFRESH_TOKEN_SECRET: joi.string().required(),
REFRESH_TOKEN_EXPIRATION: joi.string().required(),
MAIL_HOST: joi.string(),
MAIL_USERNAME: joi.string(),
MAIL_PASSWORD: joi.string(),
})
.unknown(true);
const { error, value } = envsSchema.validate(process.env);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}
const envVars: EnvVars = value;
export const envs = {
port: envVars.PORT,
dataBaseUrl: envVars.DATABASE_URL,
node_env: envVars.NODE_ENV,
host: envVars.HOST,
allow_cors_url: envVars.ALLOW_CORS_URL,
access_token_secret: envVars.ACCESS_TOKEN_SECRET,
access_token_expiration: envVars.ACCESS_TOKEN_EXPIRATION,
refresh_token_secret: envVars.REFRESH_TOKEN_SECRET,
refresh_token_expiration: envVars.REFRESH_TOKEN_EXPIRATION,
mail_host: envVars.MAIL_HOST,
mail_username: envVars.MAIL_USERNAME,
mail_password: envVars.MAIL_PASSWORD
};

View File

@@ -0,0 +1 @@
export * from './role';

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
// Cambiamos de un enum estático a un tipo string para soportar roles dinámicos
export const roleSchema = z.string();
export type Role = z.infer<typeof roleSchema>;

View File

@@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './roles.decorator';
export * from './user.decorator';

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../constants';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, IsString, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@ApiPropertyOptional({ default: 1, description: 'Page number' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10, description: 'Items per page' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number;
@ApiPropertyOptional({ description: 'Search term' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ default: 'id', description: 'Field to sort by' })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ default: 'asc', enum: ['asc', 'desc'], description: 'Sort order' })
@IsOptional()
@IsString()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
}

View File

@@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';

View File

@@ -0,0 +1,81 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from 'src/common/decorators';
import { envs } from '../config/envs';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject } from '@nestjs/common';
import { roles, usersRole } from 'src/database/schema/auth';
import { eq } from 'drizzle-orm';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
@Inject(DRIZZLE_PROVIDER)
private readonly drizzle: NodePgDatabase,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: envs.access_token_secret,
});
// Asegurarse de que el payload contiene el ID del usuario
if (!payload.sub && !payload.id) {
throw new UnauthorizedException('Invalid token payload');
}
const userId = payload.sub || payload.id;
// Obtener los roles del usuario desde la base de datos
const userRoles = await this.drizzle
.select({ name: roles.name })
.from(roles)
.innerJoin(usersRole, eq(usersRole.roleId, roles.id))
.where(eq(usersRole.userId, userId));
// Verificar si el usuario tiene el rol SUPERADMIN
const isSuperAdmin = userRoles.some(role => role.name === 'superadmin');
// Adjuntar el usuario a la solicitud con el ID correcto y sus roles
request.user = {
id: userId,
username: payload.username,
email: payload.email,
roles: userRoles.map(role => role.name),
isSuperAdmin, // Añadir flag para indicar si es SUPERADMIN
};
} catch (error) {
throw new UnauthorizedException('Invalid Access Token');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,49 @@
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Request } from 'express';
import * as schema from 'src/database/index';
import { envs } from '../config/envs';
@Injectable()
export class JwtRefreshGuard implements CanActivate {
constructor(
private jwtService: JwtService,
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
request.user = await this.jwtService.verifyAsync(token, {
secret: envs.refresh_token_secret,
});
} catch {
const session = await this.drizzle
.select()
.from(schema.sessions)
.where(eq(schema.sessions, token));
if (session.length === 0) {
throw new UnauthorizedException('Invalid Refresh Token');
}
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { IS_PUBLIC_KEY } from '../decorators';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
// Si el usuario es SUPERADMIN, permitir acceso sin verificar más
if (user.isSuperAdmin) {
return true;
}
// Verificar si el usuario tiene alguno de los roles requeridos
return requiredRoles.some(role =>
user.roles.includes(role)
);
}
}

View File

@@ -0,0 +1 @@
export * from './req-log.interceptor';

View File

@@ -0,0 +1,36 @@
import { concatStr } from '@/common/utils';
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class ReqLogInterceptor implements NestInterceptor {
private readonly logger: Logger;
constructor() {
this.logger = new Logger('REQUEST INTERCEPTOR', { timestamp: true });
}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const res = context.switchToHttp().getResponse();
/* *
* Before the request is handled, log the request details
* */
this.logger.log(concatStr([req.method, req.originalUrl]));
return next.handle().pipe(
tap(() =>
/* *
* After the request is handled, log the response details
* */
this.logger.log(
concatStr([req.method, req.originalUrl, res.statusCode]),
),
),
);
}
}

View File

@@ -0,0 +1 @@
export * from './logger.middleware';

View File

@@ -0,0 +1,15 @@
import { concatStr } from '@/common/utils';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
constructor(private readonly logger: Logger) {}
use(req: Request, res: Response, next: NextFunction) {
this.logger.log(
concatStr([req.method, req.originalUrl, res.statusCode]),
'Request',
);
next();
}
}

View File

@@ -0,0 +1,3 @@
export * from './logger.module';
export * from './node-mailer.module';
export * from './throttle.module';

View File

@@ -0,0 +1,24 @@
import { Env } from '@/common/utils';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule as PinoModule } from 'nestjs-pino';
@Module({
imports: [
PinoModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Env>) => ({
pinoHttp: {
quietReqLogger: false,
quietResLogger: false,
transport: {
target:
config.get('NODE_ENV') !== 'production' ? 'pino-pretty' : '',
},
},
}),
}),
],
})
export class LoggerModule {}

View File

@@ -0,0 +1,23 @@
import { Env } from '@/common/utils';
import { MailerModule } from '@nestjs-modules/mailer';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
MailerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Env>) => ({
transport: {
service: config.get('MAIL_HOST'),
auth: {
user: config.get('MAIL_USERNAME'),
pass: config.get('MAIL_PASSWORD'),
},
},
}),
}),
],
})
export class NodeMailerModule {}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{
name: 'short',
ttl: 1000, // 1 sec
limit: 2,
},
{
name: 'medium',
ttl: 10000, // 10 sec
limit: 4,
},
{
name: 'long',
ttl: 60000, // 1 min
limit: 10,
},
],
errorMessage: 'Too many requests, please try again later.',
}),
],
})
export class ThrottleModule {}

View File

@@ -0,0 +1,38 @@
import { FileValidator } from '@nestjs/common';
import { IFile } from '@nestjs/common/pipes/file/interfaces';
export interface FileSizeValidatorOptions {
fileSize: number;
}
/**
* Defines the built-in FileType File Validator. It validates incoming files mime-type
* matching a string or a regular expression. Note that this validator uses a naive strategy
* to check the mime-type and could be fooled if the client provided a file with renamed extension.
* (for instance, renaming a 'malicious.bat' to 'malicious.jpeg'). To handle such security issues
* with more reliability, consider checking against the file's [magic-numbers](https://en.wikipedia.org/wiki/Magic_number_%28programming%29)
*
* @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
*
* @publicApi
*/
export class FileSizeValidatorPipe extends FileValidator<
FileSizeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
return `Max file size is ${(this.validationOptions.fileSize * 0.000001).toFixed()} Mb`;
}
isValid(file?: IFile): boolean {
if (!this.validationOptions) {
return true;
}
return (
!!file &&
'mimetype' in file &&
+file.size < this.validationOptions.fileSize
);
}
}

View File

@@ -0,0 +1,38 @@
import { FileValidator } from '@nestjs/common';
import { IFile } from '@nestjs/common/pipes/file/interfaces';
export interface FileTypeValidatorOptions {
fileType: string[];
}
/**
* Defines the built-in FileType File Validator. It validates incoming files mime-type
* matching a string or a regular expression. Note that this validator uses a naive strategy
* to check the mime-type and could be fooled if the client provided a file with renamed extension.
* (for instance, renaming a 'malicious.bat' to 'malicious.jpeg'). To handle such security issues
* with more reliability, consider checking against the file's [magic-numbers](https://en.wikipedia.org/wiki/Magic_number_%28programming%29)
*
* @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
*
* @publicApi
*/
export class FileTypeValidatorPipe extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
return `File must be ${this.validationOptions.fileType}`;
}
isValid(file?: IFile): boolean {
if (!this.validationOptions) {
return true;
}
return (
!!file &&
'mimetype' in file &&
this.validationOptions.fileType.includes(file.mimetype)
);
}
}

View File

@@ -0,0 +1,3 @@
export * from './file-size-validator.pipe';
export * from './file-type-validator.pipe';
export * from './zod-validator.pipe';

View File

@@ -0,0 +1,17 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { ZodSchema, z } from 'zod';
export class ZodValidatorPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(
value: unknown,
// metadata: ArgumentMetadata,
): z.infer<typeof this.schema> {
const validateFields = this.schema.safeParse(value);
if (!validateFields.success)
throw new BadRequestException({
errors: validateFields.error.flatten().fieldErrors,
});
return validateFields.data;
}
}

View File

@@ -0,0 +1,16 @@
import * as bcrypt from 'bcryptjs';
import { compare, hash } from 'bcryptjs';
const hashString = async (password: string): Promise<string> => {
const salt = await bcrypt.genSalt(10);
return hash(password, salt);
};
const validateString = async (
plainPassword: string,
hashedPassword: string,
): Promise<boolean> => {
return await compare(plainPassword, hashedPassword);
};
export { hashString, validateString };

View File

@@ -0,0 +1,28 @@
/* eslint-disable prettier/prettier */
import * as moment from 'moment';
export const getExpiry = (cant: number) => {
const createdAt = new Date();
const expiresAt = moment(createdAt).add(cant, 'days').toDate();
return expiresAt;
};
export const getExpiryCode = (cant: number) => {
const createdAt = new Date();
const expiresAt = moment(createdAt).add(cant, 'seconds').toDate();
return expiresAt;
};
export function isDateExpired(expiry: Date): boolean {
const expirationDate = new Date(expiry);
const currentDate = new Date();
return expirationDate.getTime() <= currentDate.getTime();
}
export const expirationTimeInSeconds = (cant: number) => {
const currentTimeInMillis = Date.now();
const iat = Math.floor(currentTimeInMillis / 1000);
const expirationTimeInSeconds = cant * 24 * 60 * 60;
const exp = iat + expirationTimeInSeconds;
return exp;
};

View File

@@ -0,0 +1,16 @@
export const isEmptyObj = (obj: object) =>
Object.keys(obj).length === 0 && obj.constructor === Object;
export const concatStr = (
strings: (number | string)[],
divider?: string,
): string => strings.join(divider ?? ' ');
export const getRandomInt = (min: number, max: number) => {
const minCelled = Math.ceil(min),
maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCelled) + minCelled); // The maximum is exclusive and the minimum is inclusive
};
export * from './bcrypt';
export * from './validateEnv';

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
export const EnvSchema = z.object({
HOST: z.string(),
NODE_ENV: z
.enum(['development', 'production', 'test', 'provision'])
.default('development'),
PORT: z
.string()
.default('8000')
.transform((data: any) => +data),
ALLOW_CORS_URL: z.string().url(),
ACCESS_TOKEN_SECRET: z.string().min(10).max(128),
ACCESS_TOKEN_EXPIRATION: z.string().min(1).max(60),
REFRESH_TOKEN_SECRET: z.string().min(10).max(128),
REFRESH_TOKEN_EXPIRATION: z.string().min(1).max(365),
DB_HOST: z.string(),
DB_PORT: z.string(),
DB_USERNAME: z.string(),
DB_PASSWORD: z.string(),
DB_NAME: z.string(),
MAIL_HOST: z.string(),
MAIL_USERNAME: z.string(),
MAIL_PASSWORD: z.string(),
DATABASE_URL: z.string(),
});
export type Env = z.infer<typeof EnvSchema>;
export const validateEnv = (config: Record<string, unknown>): Env => {
const validate = EnvSchema.safeParse(config);
if (!validate.success) {
throw new Error(validate.error.message);
}
return validate.data;
};