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

1
.commitlintrc.ts Normal file
View File

@@ -0,0 +1 @@
export default { extends: ['@commitlint/config-conventional'] };

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
# Dependencies
node_modules
.pnp
.pnp.js
pnpm-lock.yaml
# Local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem

4
.husky/commit-msg Normal file
View File

@@ -0,0 +1,4 @@
# #!/usr/bin/env sh
# . "$(dirname -- "$0")/_/husky.sh"
# npx commitlint --edit $1

3
.lintstagedrc Normal file
View File

@@ -0,0 +1,3 @@
{
"*.{js,jsx,ts,tsx}": ["prettier . --write"]
}

0
.npmrc Normal file
View File

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
pnpm-lock.yaml
.gitignore
.idea
.turbo
.changeset
pnpm-workspace.yaml

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"singleQuote": true,
"trailingComma": "all",
"plugins": [
"prettier-plugin-tailwindcss",
"prettier-plugin-css-order",
"prettier-plugin-organize-imports",
"prettier-plugin-packagejson"
],
"tailwindFunctions": ["clsx", "cn", "twMerge"]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Aung Pyae Phyo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
## NestJS & NextJS
Repositorio Sistema Base Fondemi.
### **Caracteristicas**
- Backend `NestJS (v11)`
- Frontend `NextJS (v15)`
- `SWC` para una transpilación rápida de TypeScript y JavaScript
- `pnpm` para una gestión eficiente de dependencias
- Autenticación con token de acceso y token de actualización `JWT` para un acceso seguro a la API
- Base de datos `PostgreSQL` con Drizzle ORM
- `Nodemailer` para servicios de correo electrónico
- `Linting` y `Formatting` preconfigurados para la calidad del código
- Compatibilidad con `Micro-Frontend` con Turborepo
- Integración con `Shadcn/UI` para componentes con estilo
- Integración con `Tailwindcss(v4)` en `@repo/shadcn`
### **Tabla de contenido**
- Installation
- Getting Started
- Project Structure
- Scripts
- Contributing
- License
### **Installation**
Clona el repositorio:
```shell
git clone https://git.fondemi.gob.ve/Fondemi/sistema_base.git
```
Clona las variables de entorno y reemplaz la informacion:
```shell
cp .env.exmple .env
```
Instala dependencias usando pnpm:
```shell
pnpm install
```
Migra la base de datos:
```shell
pnpm db:migrate
```
Inicio
Inicio el servidor en desarrollo, run:
```shell
pnpm dev
```
Estructura del proyecto
El repositorio está organizado de la siguiente manera:
```yaml
turborepo
├── .husky # Git hooks
├── apps
│ ├── api # NestJS application
│ └── web # NextJS application
├── packages
│ ├── shadcn # shadcn/UI component library
│ ├── ts-config # Shared typescript configuration files
│ ├── eslint-config # Shared eslint configuration files
└── turbo.json # Turborepo configuration
```
### Caracteristicas del sistema
- Administración de encuestas
- Responder encuestas
- Registro usuario
- Login de usuario
- Estadisticas de encuestas

19
apps/api/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Server Configuration
HOST=localhost
PORT=8000
ALLOW_CORS_URL=http://localhost:3000
NODE_ENV='development' #development | production
#Jwt Securtiy
ACCESS_TOKEN_SECRET=bc63d848ca6e651b3b848bd96ef1ad1eb9b31afc9cad67ed5953efd023d02ffe
ACCESS_TOKEN_EXPIRATION=2h
REFRESH_TOKEN_SECRET=bc63d848ca6e651b3b848bd96ef1ad1eb9b31afc9cad67ed5953efd023d02ffe
REFRESH_TOKEN_EXPIRATION=30d
#Database Configuration
DATABASE_URL="postgresql://postgres:local**@localhost:5432/caja_ahorro" #url conexion a base de datos
#Mail Configuration
MAIL_HOST=gmail
MAIL_USERNAME=
MAIL_PASSWORD=

56
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

13
apps/api/.swcrc Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://swc.rs/schema.json",
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"baseUrl": "./"
},
"minify": false
}

1
apps/api/README.md Normal file
View File

@@ -0,0 +1 @@
### Backend

View File

@@ -0,0 +1,4 @@
import { nestJsConfig } from '@repo/eslint-config/nest-js';
/** @type {import("eslint").Linter.Config} */
export default nestJsConfig;

10
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
}

87
apps/api/package.json Normal file
View File

@@ -0,0 +1,87 @@
{
"name": "api",
"private": true,
"scripts": {
"build": "nest build",
"db:generate": "drizzle-kit generate --config ./src/drizzle.config.ts",
"db:migrate": "drizzle-kit migrate --config ./src/drizzle.config.ts",
"db:push": "drizzle-kit push --config ./src/drizzle.config.ts",
"db:seed": "ts-node -r tsconfig-paths/register ./src/database/seeds/index.ts",
"dev": "nest start -b swc -w",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"start": "node dist/main",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start",
"test": "jest",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:watch": "jest --watch"
},
"jest": {
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"drizzle-orm": "^0.40.0",
"joi": "^17.13.3",
"moment": "^2.30.1",
"path-to-regexp": "^8.2.0",
"pg": "^8.13.3",
"pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/cli": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nestjs/throttler": "^6.3.0",
"@repo/eslint-config": "workspace:*",
"@repo/ts-config": "workspace:*",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.14",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/multer": "^1.4.12",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"bcryptjs": "^3.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"drizzle-kit": "^0.30.5",
"jest": "^29.5.0",
"nestjs-pino": "^4.1.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.3"
}
}

View File

@@ -0,0 +1,64 @@
import { JwtAuthGuard, RolesGuard } from '@/common/guards';
import {
LoggerModule,
NodeMailerModule,
ThrottleModule,
} from '@/common/modules';
import { UsersModule } from '@/features/users/users.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ThrottlerGuard } from '@nestjs/throttler';
import { DrizzleModule } from './database/drizzle.module';
import { AuthModule } from './features/auth/auth.module';
import { ConfigurationsModule } from './features/configurations/configurations.module';
import { LocationModule} from './features/location/location.module'
import { MailModule } from './features/mail/mail.module';
import { RolesModule } from './features/roles/roles.module';
import { UserRolesModule } from './features/user-roles/user-roles.module';
import { SurveysModule } from './features/surveys/surveys.module';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
// {
// provide: APP_GUARD,
// useClass: PermissionsGuard,
// },
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
imports: [
JwtModule.register({
global: true,
}),
ConfigModule.forRoot({
isGlobal: true,
//validate: validateEnv,
}),
NodeMailerModule,
LoggerModule,
ThrottleModule,
UsersModule,
AuthModule,
MailModule,
DrizzleModule,
RolesModule,
UserRolesModule,
ConfigurationsModule,
SurveysModule,
LocationModule
],
})
export class AppModule {}

35
apps/api/src/bootstrap.ts Normal file
View File

@@ -0,0 +1,35 @@
import { swagger } from '@/swagger';
import { ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Logger } from 'nestjs-pino';
import { envs } from './common/config/envs';
export const bootstrap = async (app: NestExpressApplication) => {
const logger = app.get(Logger);
// app.setGlobalPrefix('api');
app.useStaticAssets('./uploads', {
prefix: '/assets',
});
app.enableCors({
credentials: true,
//origin: envs.allow_cors_url,
origin: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
});
app.useLogger(logger);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
await swagger(app);
await app.listen(envs.port!, () => {
logger.log(`This application started at ${envs.host}:${envs.port}`);
});
};

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;
};

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './index';
import { envs } from 'src/common/config/envs';
export const DRIZZLE_PROVIDER = 'DRIZZLE_PROVIDER';
export type DrizzleDatabase = NodePgDatabase<typeof schema>;
export const DrizzleProvider: Provider = {
provide: DRIZZLE_PROVIDER,
useFactory: () => {
const pool = new Pool({
connectionString: envs.dataBaseUrl,
ssl:
envs.node_env === 'production' ? { rejectUnauthorized: false } : false,
});
return drizzle(pool, { schema }) as DrizzleDatabase;
},
};

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { DrizzleProvider } from './drizzle-provider';
@Global()
@Module({
imports: [],
providers: [DrizzleProvider],
exports: [DrizzleProvider],
})
export class DrizzleModule {}

View File

@@ -0,0 +1,5 @@
export * from './schema/activity_logs';
export * from './schema/auth';
export * from './schema/general';
export * from './schema/surveys'

View File

@@ -0,0 +1,173 @@
CREATE SCHEMA "auth";
--> statement-breakpoint
CREATE TYPE "auth"."gender" AS ENUM('FEMENINO', 'MASCULINO');--> statement-breakpoint
CREATE TYPE "public"."nationality" AS ENUM('VENEZOLANO', 'EXTRANJERO');--> statement-breakpoint
CREATE TYPE "auth"."status" AS ENUM('ACTIVE', 'INACTIVE');--> statement-breakpoint
CREATE TABLE "activity_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" integer,
"type" text NOT NULL,
"description" text NOT NULL,
"timestamp" timestamp DEFAULT now(),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "auth"."roles" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "auth"."sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" integer NOT NULL,
"session_token" text NOT NULL,
"expires_at" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "auth"."users" (
"id" serial PRIMARY KEY NOT NULL,
"username" text NOT NULL,
"email" text NOT NULL,
"fullname" text NOT NULL,
"phone" text,
"password" text NOT NULL,
"is_two_factor_enabled" boolean DEFAULT false NOT NULL,
"two_factor_secret" text,
"is_email_verified" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3),
CONSTRAINT "users_username_unique" UNIQUE("username"),
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "auth"."user_role" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"role_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "auth"."verificationToken" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"code" integer,
"expires" timestamp NOT NULL,
"ip_address" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "category_type" (
"id" serial PRIMARY KEY NOT NULL,
"group" varchar(100) NOT NULL,
"description" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "localities" (
"id" serial PRIMARY KEY NOT NULL,
"state_id" integer NOT NULL,
"municipality_id" integer NOT NULL,
"parish_id" integer NOT NULL,
"name" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3),
CONSTRAINT "localities_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "municipalities" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"state_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "parishes" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"municipality_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "states" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "answers_surveys" (
"id" serial PRIMARY KEY NOT NULL,
"survey_id" integer,
"user_id" integer,
"answers" jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "surveys" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"target_audience" varchar(50) NOT NULL,
"closing_date" date,
"published" boolean NOT NULL,
"questions" jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."user_role" ADD CONSTRAINT "user_role_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."user_role" ADD CONSTRAINT "user_role_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "auth"."roles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "localities" ADD CONSTRAINT "localities_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "public"."states"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "localities" ADD CONSTRAINT "localities_municipality_id_municipalities_id_fk" FOREIGN KEY ("municipality_id") REFERENCES "public"."municipalities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "localities" ADD CONSTRAINT "localities_parish_id_parishes_id_fk" FOREIGN KEY ("parish_id") REFERENCES "public"."parishes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "municipalities" ADD CONSTRAINT "municipalities_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "public"."states"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "parishes" ADD CONSTRAINT "parishes_municipality_id_municipalities_id_fk" FOREIGN KEY ("municipality_id") REFERENCES "public"."municipalities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "answers_surveys" ADD CONSTRAINT "answers_surveys_survey_id_surveys_id_fk" FOREIGN KEY ("survey_id") REFERENCES "public"."surveys"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "answers_surveys" ADD CONSTRAINT "answers_surveys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "activityLogs_idx" ON "activity_logs" USING btree ("type");--> statement-breakpoint
CREATE INDEX "roles_idx" ON "auth"."roles" USING btree ("name");--> statement-breakpoint
CREATE INDEX "sessions_idx" ON "auth"."sessions" USING btree ("session_token");--> statement-breakpoint
CREATE INDEX "users_idx" ON "auth"."users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "user_role_idx" ON "auth"."user_role" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "category_typeIx0" ON "category_type" USING btree ("group");--> statement-breakpoint
CREATE INDEX "category_typeIx1" ON "category_type" USING btree ("description");--> statement-breakpoint
CREATE UNIQUE INDEX "localities_index_03" ON "localities" USING btree ("state_id","municipality_id","parish_id");--> statement-breakpoint
CREATE INDEX "localities_index_00" ON "localities" USING btree ("state_id");--> statement-breakpoint
CREATE INDEX "localities_index_01" ON "localities" USING btree ("municipality_id");--> statement-breakpoint
CREATE INDEX "localities_index_02" ON "localities" USING btree ("parish_id");--> statement-breakpoint
CREATE INDEX "municipalities_index_00" ON "municipalities" USING btree ("id","name","state_id");--> statement-breakpoint
CREATE INDEX "parishes_index_00" ON "parishes" USING btree ("id","name","municipality_id");--> statement-breakpoint
CREATE INDEX "states_index_00" ON "states" USING btree ("id","name");--> statement-breakpoint
CREATE INDEX "answers_index_00" ON "answers_surveys" USING btree ("answers");--> statement-breakpoint
CREATE INDEX "answers_index_01" ON "answers_surveys" USING btree ("survey_id");--> statement-breakpoint
CREATE INDEX "answers_index_02" ON "answers_surveys" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "surveys_index_00" ON "surveys" USING btree ("title");--> statement-breakpoint
CREATE VIEW "auth"."user_access_view" AS (
SELECT
u.id AS user_id,
u.username,
u.email,
u.fullname,
r.id AS role_id,
r.name AS role_name
FROM
auth.users u
LEFT JOIN
auth.user_role ur ON u.id = ur.user_id
LEFT JOIN
auth.roles r ON ur.role_id = r.id);--> statement-breakpoint
CREATE VIEW "public"."v_surveys" AS (select s.id as survey_id, s.title, s.description, s.created_at, s.closing_date, s.target_audience, as2.user_id from surveys s
left join answers_surveys as2 on as2.survey_id = s.id
where s.published = true);

View File

@@ -0,0 +1,9 @@
DROP VIEW "public"."v_surveys";--> statement-breakpoint
ALTER TABLE "auth"."users" ADD COLUMN "state" integer;--> statement-breakpoint
ALTER TABLE "auth"."users" ADD COLUMN "municipality" integer;--> statement-breakpoint
ALTER TABLE "auth"."users" ADD COLUMN "parish" integer;--> statement-breakpoint
ALTER TABLE "auth"."users" ADD CONSTRAINT "users_state_states_id_fk" FOREIGN KEY ("state") REFERENCES "public"."states"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."users" ADD CONSTRAINT "users_municipality_municipalities_id_fk" FOREIGN KEY ("municipality") REFERENCES "public"."municipalities"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."users" ADD CONSTRAINT "users_parish_parishes_id_fk" FOREIGN KEY ("parish") REFERENCES "public"."parishes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE VIEW "public"."v_surveys" AS (select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1743783835462,
"tag": "0000_abnormal_lethal_legion",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1747665408016,
"tag": "0001_massive_kylun",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,32 @@
import * as t from 'drizzle-orm/pg-core';
import { index, pgTable } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
const timestamps = {
created_at: t.timestamp('created_at').defaultNow().notNull(),
updated_at: t
.timestamp('updated_at', { mode: 'date', precision: 3 })
.$onUpdate(() => new Date()),
};
// Tabla de Logs de Actividad
export const activityLogsSystem = pgTable(
'activity_logs',
{
id: t
.uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
userId: t
.integer('user_id')
.references(() => users.id, { onDelete: 'cascade' }),
type: t.text('type').notNull(), // login, failed
description: t.text('description').notNull(),
timestamp: t.timestamp('timestamp').defaultNow(),
...timestamps,
},
(activityLogs) => ({
activityLogsIdx: index('activityLogs_idx').on(activityLogs.type),
}),
);

View File

@@ -0,0 +1,132 @@
import * as t from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { authSchema } from './schemas';
import { timestamps } from '../timestamps';
import { states, municipalities, parishes } from './general';
// Tabla de Usuarios sistema
export const users = authSchema.table(
'users',
{
id: t.serial('id').primaryKey(),
username: t.text('username').unique().notNull(),
email: t.text('email').unique().notNull(),
fullname: t.text('fullname').notNull(),
phone: t.text('phone'),
password: t.text('password').notNull(),
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
isTwoFactorEnabled: t
.boolean('is_two_factor_enabled')
.notNull()
.default(false),
twoFactorSecret: t.text('two_factor_secret'),
isEmailVerified: t.boolean('is_email_verified').notNull().default(false),
isActive: t.boolean('is_active').notNull().default(true),
...timestamps,
},
(users) => ({
usersIdx: t.index('users_idx').on(users.username),
}),
);
// Tabla de Roles
export const roles = authSchema.table(
'roles',
{
id: t.serial('id').primaryKey(),
name: t.text('name').notNull(),
...timestamps,
},
(roles) => ({
rolesIdx: t.index('roles_idx').on(roles.name),
}),
);
//tabla User_roles
export const usersRole = authSchema.table(
'user_role',
{
id: t.serial('id').primaryKey(),
userId: t
.integer('user_id')
.references(() => users.id, { onDelete: 'cascade' }),
roleId: t
.integer('role_id')
.references(() => roles.id, { onDelete: 'set null' }),
...timestamps,
},
(userRole) => ({
userRoleIdx: t.index('user_role_idx').on(userRole.userId),
}),
);
export const userAccessView = authSchema.view('user_access_view', {
userId: t.integer('userId').notNull(),
username: t.text('username').notNull(),
email: t.text('email').notNull(),
fullname: t.text('email').notNull(),
roleId: t.integer('role_id'),
roleName: t.text('role_name'),
}).as(sql`
SELECT
u.id AS user_id,
u.username,
u.email,
u.fullname,
r.id AS role_id,
r.name AS role_name
FROM
auth.users u
LEFT JOIN
auth.user_role ur ON u.id = ur.user_id
LEFT JOIN
auth.roles r ON ur.role_id = r.id`);
// Tabla de Sesiones
export const sessions = authSchema.table(
'sessions',
{
id: t
.uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
userId: t
.integer('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
sessionToken: t.text('session_token').notNull(),
expiresAt: t.integer('expires_at').notNull(),
...timestamps,
},
(sessions) => ({
sessionsIdx: t.index('sessions_idx').on(sessions.sessionToken),
}),
);
//tabla de tokens de verificación
export const verificationTokens = authSchema.table(
'verificationToken',
{
identifier: t.text('identifier').notNull(),
token: t.text('token').notNull(),
code: t.integer('code'),
expires: t.timestamp('expires', { mode: 'date' }).notNull(),
ipAddress: t.text('ip_address').notNull(),
},
(verificationToken) => [
{
compositePk: t.primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
},
],
);

View File

@@ -0,0 +1,8 @@
import { pgEnum } from 'drizzle-orm/pg-core';
import { authSchema } from './schemas';
export const statusEnum = authSchema.enum('status', ['ACTIVE', 'INACTIVE']);
export const genderEnum = authSchema.enum('gender', ['FEMENINO', 'MASCULINO']);
export const nationalityEnum = pgEnum('nationality', [
'VENEZOLANO',
'EXTRANJERO',
]);

View File

@@ -0,0 +1,114 @@
import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../timestamps';
//Tabla de Tipo de categorias
export const categoryType = t.pgTable(
'category_type',
{
id: t.serial('id').primaryKey(),
group: t.varchar('group', { length: 100 }).notNull(), // grupo pertenece
description: t.text('description').notNull(), // name
...timestamps,
},
(categoryType) => ({
categoryTypeIdx0: t.index('category_typeIx0').on(categoryType.group),
categoryTypeIdx1: t.index('category_typeIx1').on(categoryType.description),
}),
);
// Tabla States
export const states = t.pgTable(
'states',
{
id: t.serial('id').primaryKey(),
name: t.text('name').notNull(),
...timestamps,
},
(states) => ({
nameIndex: t.index('states_index_00').on(states.id, states.name),
}),
);
// Tabla Municipalities
export const municipalities = t.pgTable(
'municipalities',
{
id: t.serial('id').primaryKey(),
name: t.text('name').notNull(),
stateId: t
.integer('state_id')
.notNull()
.references(() => states.id, { onDelete: 'cascade' }),
...timestamps,
},
(municipalities) => ({
nameStateIndex: t
.index('municipalities_index_00')
.on(municipalities.id, municipalities.name, municipalities.stateId),
}),
);
// Tabla Parishes
export const parishes = t.pgTable(
'parishes',
{
id: t.serial('id').primaryKey(),
name: t.text('name').notNull(),
municipalityId: t
.integer('municipality_id')
.notNull()
.references(() => municipalities.id, { onDelete: 'cascade' }),
...timestamps,
},
(parishes) => ({
parishIndex: t
.index('parishes_index_00')
.on(parishes.id, parishes.name, parishes.municipalityId),
}),
);
// Tabla Localities
export const localities = t.pgTable(
'localities',
{
id: t.serial('id').primaryKey(),
stateId: t
.integer('state_id')
.notNull()
.references(() => states.id, { onDelete: 'cascade' }),
municipalityId: t
.integer('municipality_id')
.notNull()
.references(() => municipalities.id, { onDelete: 'cascade' }),
parishId: t
.integer('parish_id')
.notNull()
.references(() => parishes.id, { onDelete: 'cascade' }),
name: t.text('name').unique().notNull(),
...timestamps,
},
(localities) => ({
uniqueLocalityIndex: t
.uniqueIndex('localities_index_03')
.on(localities.stateId, localities.municipalityId, localities.parishId),
stateIndex: t.index('localities_index_00').on(localities.stateId),
municipalityIndex: t
.index('localities_index_01')
.on(localities.municipalityId),
parishIndex: t.index('localities_index_02').on(localities.parishId),
}),
);
// // Vista LocalitiesView
// export const localitiesView = t.pgView("localities_view", {
// id: t.integer("id").notNull(),
// stateId: t.integer("state_id"),
// state: t.text("state"),
// municipalityId: t.integer("municipality_id"),
// municipality: t.text("municipality"),
// parishId: t.integer("parish_id"),
// parish: t.text("parish"),
// fullLocation: t.text("full_location"),
// });

View File

@@ -0,0 +1,4 @@
//schemas
import * as t from 'drizzle-orm/pg-core';
export const authSchema = t.pgSchema('auth'); //autenticacion y sessiones usuarios

View File

@@ -0,0 +1,57 @@
import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps';
import { users } from './auth';
// Tabla surveys
export const surveys = t.pgTable(
'surveys',
{
id: t.serial('id').primaryKey(),
title: t.text('title').notNull(),
description: t.text('description').notNull(),
targetAudience: t.varchar('target_audience', { length: 50 }).notNull(),
closingDate: t.date('closing_date'),
published: t.boolean('published').notNull(),
questions: t.jsonb('questions').notNull(),
...timestamps,
},
(surveys) => ({
surveysIndex: t
.index('surveys_index_00')
.on(surveys.title),
}),
);
export const answersSurveys = t.pgTable(
'answers_surveys',
{
id: t.serial('id').primaryKey(),
surveyId: t
.integer('survey_id')
.references(() => surveys.id, { onDelete: 'cascade' }),
userId: t
.integer('user_id')
.references(() => users.id, { onDelete: 'cascade' }),
answers: t.jsonb('answers').notNull(),
...timestamps,
},
(answers) => ({
answersIndex: t.index('answers_index_00').on(answers.answers),
answersIndex01: t.index('answers_index_01').on(answers.surveyId),
answersIndex02: t.index('answers_index_02').on(answers.userId),
}),
);
export const viewSurveys = t.pgView('v_surveys', {
surverId: t.integer('survey_id'),
title: t.text('title'),
description: t.text('description'),
created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience')
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -0,0 +1,24 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { roles, } from '../index';
export async function seedAdminRole(db: NodePgDatabase<typeof schema>) {
console.log('Seeding admin role...');
// Insert roles
const roleNames = ['superadmin', 'admin', 'autoridad','manager','user','producers','organization'];
for (const roleName of roleNames) {
try {
await db.insert(roles).values({
name: roleName
}).onConflictDoNothing();
console.log(`Role '${roleName}' created or already exists`);
} catch (error) {
console.error(`Error creating role '${roleName}':`, error);
}
}
console.log('roles seeded successfully');
}

View File

@@ -0,0 +1,37 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { envs } from 'src/common/config/envs';
import * as schema from '../index';
import { seedAdminRole } from './admin-role.seed';
import { seedUserAdmin } from './user-admin.seed';
import { seedStates } from './states.seed';
import { seedMunicipalities } from './municipalities.seed';
import { seedParishes } from './parishes.seed';
async function main() {
const pool = new Pool({
connectionString: envs.dataBaseUrl,
ssl:
envs.node_env === 'production' ? { rejectUnauthorized: false } : false,
});
const db = drizzle(pool, { schema });
try {
// Run seeds in order
await seedStates(db);
await seedMunicipalities(db);
await seedParishes(db);
await seedAdminRole(db);
await seedUserAdmin(db)
console.log('All seeds completed successfully');
} catch (error) {
console.error('Error seeding database:', error);
} finally {
await pool.end();
}
}
main();

View File

@@ -0,0 +1,25 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { municipalities } from '../schema/general';
export async function seedMunicipalities(db: NodePgDatabase<typeof schema>) {
console.log('Seeding public municipalities...');
// Insert roles
const municipalitiesArray = [{name:'municipio1',stateId:1}, {name:'municipio2',stateId:1}, {name:'municipio3',stateId:2}];
for (const item of municipalitiesArray) {
try {
await db.insert(municipalities).values({
name: item.name,
stateId: item.stateId
}).onConflictDoNothing();
// console.log(`Municipality '${item}' created or already exists`);
} catch (error) {
console.error(`Error creating municipality '${item.name}':`, error);
}
}
console.log('All municipalities seeded successfully');
}

View File

@@ -0,0 +1,25 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { parishes } from '../schema/general';
export async function seedParishes(db: NodePgDatabase<typeof schema>) {
console.log('Seeding public parishes...');
// Insert roles
const parishesArray = [{name:'parroquia1',municipalityId:1}, {name:'parroquia2',municipalityId:1}, {name:'parroquia3',municipalityId:2}];
for (const item of parishesArray) {
try {
await db.insert(parishes).values({
name: item.name,
municipalityId: item.municipalityId
}).onConflictDoNothing();
// console.log(`Parish '${item}' created or already exists`);
} catch (error) {
console.error(`Error creating parish '${item.name}':`, error);
}
}
console.log('All parishes seeded successfully');
}

View File

@@ -0,0 +1,24 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { states } from '../schema/general';
export async function seedStates(db: NodePgDatabase<typeof schema>) {
console.log('Seeding public state...');
// Insert roles
const statesArray = ['estado1', 'estado2', 'estado3'];
for (const item of statesArray) {
try {
await db.insert(states).values({
name: item
}).onConflictDoNothing();
// console.log(`State '${item}' created or already exists`);
} catch (error) {
console.error(`Error creating state '${item}':`, error);
}
}
console.log('All states seeded successfully');
}

View File

@@ -0,0 +1,39 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { users, usersRole } from '../index';
export async function seedUserAdmin(db: NodePgDatabase<typeof schema>) {
// Insert admin user
try {
// Password is already hashed in your SQL, but in a real application you might want to hash it here
// const hashedPassword = await hash('your_password', 10);
const hashedPassword = '$2b$10$6esl7d/BOINamScuReRoPuYFC8iSJgpk61LHm2X3PCU5hu/St8vHW';
const [adminUser] = await db.insert(users).values({
username: 'superadmin',
email: 'admin@zonastart.com',
fullname: 'Super Administrador',
password: hashedPassword,
state: 1,
municipality: 1,
parish: 1,
isTwoFactorEnabled: false,
isEmailVerified: true,
isActive: true
}).returning({ id: users.id }).onConflictDoNothing();
if (adminUser) {
// Assign superadmin role to the user
await db.insert(usersRole).values({
roleId: 1, // Assuming 'superadmin' has ID 1 based on the insert order
userId: adminUser.id
}).onConflictDoNothing();
console.log('Admin user created and assigned superadmin role');
} else {
console.log('Admin user already exists, skipping');
}
} catch (error) {
console.error('Error creating admin user:', error);
}
}

View File

@@ -0,0 +1,8 @@
import * as t from 'drizzle-orm/pg-core';
export const timestamps = {
created_at: t.timestamp('created_at').defaultNow().notNull(),
updated_at: t
.timestamp('updated_at', { mode: 'date', precision: 3 })
.$onUpdate(() => new Date()),
};

View File

@@ -0,0 +1,13 @@
import type { Config } from 'drizzle-kit';
import { envs } from './common/config/envs';
export default {
schema: './src/database/schema/*', // Path to schema file
out: './src/database/migrations', // Path to output directory
dialect: 'postgresql', // Database dialect
schemaFilter: ["public", "auth"],
dbCredentials: {
url: envs.dataBaseUrl,
},
} satisfies Config;

View File

@@ -0,0 +1,61 @@
import { Public } from '@/common/decorators';
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import {
Body,
Controller,
HttpCode,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@HttpCode(200)
@Post('sing-up')
// @ApiOperation({ summary: 'Create a new user' })
// @ApiResponse({ status: 201, description: 'User created successfully.' })
async singUp(@Body() payload: SingUpUserDto) {
const data = await this.authService.singUp(payload)
return { message: 'User created successfully', data};
// return { message: 'User created successfully', data };
}
@Public()
@HttpCode(200)
@Post('sign-in')
async signIn(@Body() signInUserDto: SignInUserDto) {
return await this.authService.signIn(signInUserDto);
}
@Post('sign-out')
//@RequirePermissions('auth:sign-out')
async signOut(@Body() signOutUserDto: SignOutUserDto) {
await this.authService.signOut(signOutUserDto);
return { message: 'User signed out successfully' };
}
// @Post('forgot-password')
// async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
// await this.authService.forgotPassword(forgotPasswordDto);
// return { message: 'Password reset link sent to your email' };
// }
@UseGuards(JwtRefreshGuard)
@Patch('refresh-token')
//@RequirePermissions('auth:refresh-token')
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return await this.authService.refreshToken(refreshTokenDto);
}
}

View File

@@ -0,0 +1,12 @@
import { MailModule } from '@/features/mail/mail.module';
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { DrizzleModule } from '@/database/drizzle.module';
@Module({
imports: [DrizzleModule, MailModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,368 @@
import { envs } from '@/common/config/envs';
import { Env, validateString } from '@/common/utils';
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
import {
LoginUserInterface,
Roles,
} from '@/features/auth/interfaces/login-user.interface';
import RefreshTokenInterface from '@/features/auth/interfaces/refresh-token.interface';
import { MailService } from '@/features/mail/mail.service';
import { User } from '@/features/users/entities/user.entity';
import {
HttpException,
HttpStatus,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index';
import { sessions, users, roles, usersRole } from 'src/database/index';
import { Session } from './interfaces/session.interface';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService<Env>,
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
private readonly mailService: MailService,
) {}
//Decode Tokens
// Método para decodificar el token y obtener los datos completos
private decodeToken(token: string): {
sub: number;
username?: string;
iat: number;
exp: number;
} {
try {
const decoded = this.jwtService.decode(token) as {
sub: number;
username?: string;
iat: number;
exp: number;
};
// Validar que contiene los datos esenciales
if (!decoded || !decoded.exp || !decoded.iat) {
throw new Error('Token lacks required fields');
}
return decoded;
} catch (error) {
// Manejo seguro del tipo unknown
let errorMessage = 'Failed to decode token';
if (error instanceof Error) {
errorMessage = error.message;
console.error('Error decoding token:', errorMessage);
} else {
console.error('Unknown error type:', error);
}
throw new HttpException(errorMessage, HttpStatus.UNAUTHORIZED);
}
}
//Generate Tokens
async generateTokens(user: User): Promise<AuthTokensInterface> {
const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.access_token_secret,
expiresIn: envs.access_token_expiration,
},
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration,
},
),
]);
return {
access_token,
refresh_token,
};
}
//Generate OTP Code For Email Confirmation
async generateOTP(length = 6): Promise<string> {
return crypto
.randomInt(0, 10 ** length)
.toString()
.padStart(length, '0');
}
// metodo para crear una session
private async createSession(sessionInput: Session): Promise<string> {
const { userId } = sessionInput;
const activeSessionsCount = await this.drizzle
.select()
.from(sessions)
.where(eq(sessions.userId, parseInt(userId)));
if (activeSessionsCount.length !== 0) {
// Elimina sessiones viejsas
await this.drizzle
.delete(sessions)
.where(eq(sessions.userId, parseInt(userId)));
}
const session = await this.drizzle.insert(sessions).values({
sessionToken: sessionInput.sessionToken,
userId: parseInt(userId),
expiresAt: sessionInput.expiresAt,
});
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
return 'Session created successfully';
}
//Find User
async findUser(username: string): Promise<User | null> {
const user = await this.drizzle
.select()
.from(users)
.where(eq(users.username, username));
return user[0];
}
//Find User
async findUserById(id: number): Promise<User | null> {
const user = await this.drizzle
.select()
.from(users)
.where(eq(users.id, id));
return user[0];
}
//Check User Is Already Exists
async validateUser(dto: ValidateUserDto): Promise<User> {
const user = await this.findUser(dto.username);
if (!user) throw new NotFoundException('User not found');
const isValid = await validateString(
dto.password,
user?.password as string,
);
if (!isValid) throw new UnauthorizedException('Invalid credentials');
return user;
}
//Find rol user
async findUserRol(id: number): Promise<Roles[]> {
const roles = await this.drizzle
.select({
id: schema.roles.id,
role: schema.roles.name,
})
.from(schema.usersRole)
.leftJoin(schema.roles, eq(schema.roles.id, schema.usersRole.roleId))
.where(eq(schema.usersRole.userId, id));
if (roles.length === 0) {
throw new NotFoundException('User not found');
}
// Aseguramos que no haya valores nulos
return roles.map((role) => ({
id: role.id ?? 0, // Asignamos un valor por defecto (0) si es null
rol: role.role ?? '', // Asignamos un valor por defecto (cadena vacía) si es null
}));
}
//Sign In User Account
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
const user = await this.validateUser(dto);
const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token);
const decodeRefresh = this.decodeToken(tokens.refresh_token);
const rol = await this.findUserRol(user?.id as number);
await this.createSession({
userId: String(user?.id), // Convert number to string
sessionToken: tokens.refresh_token,
expiresAt: decodeRefresh.exp,
});
return {
message: 'User signed in successfully',
user: {
id: user?.id as number,
username: user?.username,
fullname: user?.fullname,
email: user?.email,
rol: rol,
},
tokens: {
access_token: tokens.access_token,
access_expire_in: decodeAccess.exp,
refresh_token: tokens.refresh_token,
refresh_expire_in: decodeRefresh.exp,
},
};
}
// //Forgot Password
// async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
// const user = await this.findUser(dto.username);
// if (!user) throw new NotFoundException('User not found');
// const passwordResetToken = await this.generateOTP();
// user.passwordResetToken = passwordResetToken;
// user.passwordResetTokenExpires = new Date(
// Date.now() + 1000 * 60 * 60 * 24, // 1 day
// );
// await this.UserRepository.save(user);
// await this.mailService.sendEmail({
// to: [user.email],
// subject: 'Reset Password',
// html: ForgotPasswordMail({
// name: user.name,
// code: passwordResetToken,
// }),
// });
// }
//Sign Out User Account
async signOut(dto: SignOutUserDto): Promise<void> {
const { user_id } = dto;
const user = await this.drizzle
.select()
.from(users)
.where(eq(users.id, parseInt(user_id)));
if (!user) throw new NotFoundException('User not found');
await this.drizzle
.delete(sessions)
.where(eq(sessions.userId, parseInt(user_id)));
}
//Refresh User Access Token
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
const { user_id } = dto;
const session = await this.drizzle
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, user_id) &&
eq(sessions.sessionToken, dto.refresh_token),
),
);
if (session.length === 0) throw new NotFoundException('session not found');
const user = await this.findUserById(dto.user_id);
if (!user) throw new NotFoundException('User not found');
const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token);
const decodeRefresh = this.decodeToken(tokens.refresh_token);
await this.drizzle
.update(sessions)
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
.where(eq(sessions.userId, dto.user_id));
return {
access_token: tokens.access_token,
access_expire_in: decodeAccess.exp,
refresh_token: tokens.refresh_token,
refresh_expire_in: decodeRefresh.exp,
};
}
async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists
const data = await this.drizzle
.select({
id: users.id,
username: users.username,
email: users.email
})
.from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
if (data.length > 0) {
if (data[0].username === createUserDto.username) {
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({
id: users.id,
username: users.username,
email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
})
.from(users)
.leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
.where(eq(users.id, newUser.id));
return userWithRole;
})
}
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class ChangePasswordDto {
@ApiProperty()
@IsString({
message: 'Identifier must be a string',
})
username: string;
@ApiProperty()
@IsString({
message: 'Password must be a string',
})
password: string;
@ApiProperty()
@IsString({
message: 'New password must be a string',
})
newPassword: string;
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
export class ConfirmEmailDto {
@ApiProperty()
@IsString()
@MaxLength(6)
@MinLength(6)
code: string;
@ApiProperty()
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@ApiProperty()
@IsEmail()
username: string;
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsEmail()
fullname: string;
@ApiProperty()
@IsString({
message: 'Phone must be a string',
})
@IsOptional()
phone: string;
@ApiProperty()
@IsString({
message: 'Password must be a string',
})
password: string;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class ForgotPasswordDto {
@ApiProperty()
@IsString({
message: 'Identifier must be a string',
})
username: string;
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString({
message: 'Refresh token must be a string',
})
refresh_token: string;
@ApiProperty()
@IsNumber()
user_id: number;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty()
@IsString({
message: 'Identifier must be a string',
})
username: string;
@ApiProperty()
@IsString({
message: 'Reset Token must be a string',
})
resetToken: string;
@ApiProperty()
@IsString({
message: 'New password must be a string',
})
newPassword: string;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class SignInUserDto {
@ApiProperty()
@IsString({
message: 'Identifier must be a string',
})
username: string;
@ApiProperty()
@IsString({
message: 'Password must be a string',
})
password: string;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class SignOutUserDto {
@ApiProperty()
@IsString({
message: 'User Id must be a string',
})
user_id: string;
}

View File

@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator';
export class SingUpUserDto {
@ApiProperty()
@IsString()
username: string;
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsString()
fullname: string;
@ApiProperty()
@IsString({
message: 'Phone must be a string',
})
@IsOptional()
phone: string;
@ApiProperty()
@IsString({
message: 'Password must be a string',
})
password: string;
@ApiProperty()
@IsInt()
state: number;
@ApiProperty()
@IsInt()
municipality: number;
@ApiProperty()
@IsInt()
parish: number;
@ApiProperty()
@IsInt()
role: number;
}

View File

@@ -0,0 +1,6 @@
import { User } from '@/features/users/entities/user.entity';
export class UpdateRefreshTokenDto {
user: User;
refresh_token: string;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class ValidateUserDto {
@ApiProperty()
@IsString({
message: 'First name must be a string',
})
username: string;
@ApiProperty()
@IsString({
message: 'Password must be a string',
})
password: string;
}

View File

@@ -0,0 +1,6 @@
interface AuthTokensInterface {
access_token: string;
refresh_token: string;
}
export default AuthTokensInterface;

View File

@@ -0,0 +1,25 @@
export interface LoginUserInterface {
message: string;
user: User;
tokens: Tokens;
}
interface User {
id: number;
username: string;
fullname: string;
email?: string;
rol: Roles[];
}
export interface Roles {
id: number;
rol: string;
}
interface Tokens {
access_token: string;
access_expire_in: number;
refresh_token: string;
refresh_expire_in: number;
}

View File

@@ -0,0 +1,8 @@
interface RefreshTokenInterface {
access_token: string;
access_expire_in: number;
refresh_token: string;
refresh_expire_in: number;
}
export default RefreshTokenInterface;

View File

@@ -0,0 +1,5 @@
export interface Session {
userId: string;
sessionToken: string;
expiresAt: number;
}

View File

@@ -0,0 +1,96 @@
import { Roles } from '@/common/decorators';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CategoryTypesService } from './category-types.service';
import { CreateCategoryTypeDto } from './dto/create-category-type.dto';
import { UpdateCategoryTypeDto } from './dto/update-category-type.dto';
import { CategoryType } from './entities/category-type.entity';
@ApiTags('Category Types')
@Controller('configurations/category-types')
export class CategoryTypesController {
constructor(private readonly categoryTypesService: CategoryTypesService) {}
@Get()
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get all category types' })
@ApiResponse({
status: 200,
description: 'Return all category types',
type: [CategoryType],
})
findAll() {
return this.categoryTypesService.findAll();
}
@Get(':id')
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get a category type by id' })
@ApiResponse({
status: 200,
description: 'Return a category type',
type: CategoryType,
})
@ApiResponse({ status: 404, description: 'Category type not found' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.categoryTypesService.findOne(id);
}
@Get('group/:group')
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get category types by group' })
@ApiResponse({
status: 200,
description: 'Return category types by group',
type: [CategoryType],
})
findByGroup(@Param('group') group: string) {
return this.categoryTypesService.findByGroup(group);
}
@Post()
@Roles('admin')
@ApiOperation({ summary: 'Create a new category type' })
@ApiResponse({
status: 201,
description: 'Category type created',
type: CategoryType,
})
create(@Body() createCategoryTypeDto: CreateCategoryTypeDto) {
return this.categoryTypesService.create(createCategoryTypeDto);
}
@Patch(':id')
@Roles('admin')
@ApiOperation({ summary: 'Update a category type' })
@ApiResponse({
status: 200,
description: 'Category type updated',
type: CategoryType,
})
@ApiResponse({ status: 404, description: 'Category type not found' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCategoryTypeDto: UpdateCategoryTypeDto,
) {
return this.categoryTypesService.update(id, updateCategoryTypeDto);
}
@Delete(':id')
@Roles('admin')
@ApiOperation({ summary: 'Delete a category type' })
@ApiResponse({ status: 200, description: 'Category type deleted' })
@ApiResponse({ status: 404, description: 'Category type not found' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.categoryTypesService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { DrizzleModule } from '@/database/drizzle.module';
import { Module } from '@nestjs/common';
import { CategoryTypesController } from './category-types.controller';
import { CategoryTypesService } from './category-types.service';
@Module({
imports: [DrizzleModule],
controllers: [CategoryTypesController],
providers: [CategoryTypesService],
exports: [CategoryTypesService],
})
export class CategoryTypesModule {}

View File

@@ -0,0 +1,81 @@
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import * as schema from '@/database/index';
import { categoryType } from '@/database/schema/general';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { CreateCategoryTypeDto } from './dto/create-category-type.dto';
import { UpdateCategoryTypeDto } from './dto/update-category-type.dto';
import { CategoryType } from './entities/category-type.entity';
@Injectable()
export class CategoryTypesService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) {}
async findAll(): Promise<CategoryType[]> {
return await this.drizzle.select().from(categoryType);
}
async findOne(id: number): Promise<CategoryType> {
const category = await this.drizzle
.select()
.from(categoryType)
.where(eq(categoryType.id, id));
if (category.length === 0) {
throw new HttpException('Category type not found', HttpStatus.NOT_FOUND);
}
return category[0];
}
async findByGroup(group: string): Promise<CategoryType[]> {
return await this.drizzle
.select()
.from(categoryType)
.where(eq(categoryType.group, group));
}
async create(
createCategoryTypeDto: CreateCategoryTypeDto,
): Promise<CategoryType> {
const [category] = await this.drizzle
.insert(categoryType)
.values({
group: createCategoryTypeDto.group,
description: createCategoryTypeDto.description,
})
.returning();
return category;
}
async update(
id: number,
updateCategoryTypeDto: UpdateCategoryTypeDto,
): Promise<CategoryType> {
// Check if category type exists
await this.findOne(id);
await this.drizzle
.update(categoryType)
.set({
group: updateCategoryTypeDto.group,
description: updateCategoryTypeDto.description,
})
.where(eq(categoryType.id, id));
return this.findOne(id);
}
async remove(id: number): Promise<{ message: string }> {
// Check if category type exists
await this.findOne(id);
await this.drizzle.delete(categoryType).where(eq(categoryType.id, id));
return { message: 'Category type deleted successfully' };
}
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateCategoryTypeDto {
@ApiProperty({
description: 'The group of the category type',
example: 'PAYROLL_TYPE',
})
@IsNotEmpty()
@IsString()
group: string;
@ApiProperty({
description: 'The description of the category type',
example: 'Quincenal',
})
@IsNotEmpty()
@IsString()
description: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryTypeDto } from './create-category-type.dto';
export class UpdateCategoryTypeDto extends PartialType(CreateCategoryTypeDto) {}

View File

@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class CategoryType {
@ApiProperty({
description: 'The unique identifier of the category type',
example: 1,
})
id: number;
@ApiProperty({
description: 'The group of the category type',
example: 'PAYROLL_TYPE',
})
group: string;
@ApiProperty({
description: 'The description of the category type',
example: 'Quincenal',
})
description: string;
@ApiProperty({
description: 'The date when the category type was created',
example: '2023-01-01T00:00:00.000Z',
})
created_at?: Date;
@ApiProperty({
description: 'The date when the category type was last updated',
example: '2023-01-01T00:00:00.000Z',
})
updated_at?: Date | null;
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { CategoryTypesModule } from './category-types/category-types.module';
import { MunicipalitiesModule } from './municipalities/municipalities.module';
import { ParishesModule } from './parishes/parishes.module';
import { StatesModule } from './states/states.module';
@Module({
imports: [
StatesModule,
MunicipalitiesModule,
ParishesModule,
CategoryTypesModule,
],
})
export class ConfigurationsModule {}

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
export class CreateMunicipalityDto {
@ApiProperty({
description: 'The name of the municipality',
example: 'Los Angeles',
})
@IsNotEmpty()
@IsString()
name: string;
@ApiProperty({
description: 'The ID of the state this municipality belongs to',
example: 1,
})
@IsNotEmpty()
@IsNumber()
stateId: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateMunicipalityDto } from './create-municipality.dto';
export class UpdateMunicipalityDto extends PartialType(CreateMunicipalityDto) {}

View File

@@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
export class Municipality {
@ApiProperty({
description: 'The unique identifier of the municipality',
example: 1,
})
id: number;
@ApiProperty({
description: 'The name of the municipality',
example: 'Los Angeles',
})
name: string;
@ApiProperty({
description: 'The ID of the state this municipality belongs to',
example: 1,
})
stateId: number;
@ApiProperty({
description: 'The name of the state this municipality belongs to',
example: 'California',
})
stateName?: string | null;
@ApiProperty({
description: 'The date when the municipality was created',
example: '2023-01-01T00:00:00.000Z',
})
created_at?: Date | null;
@ApiProperty({
description: 'The date when the municipality was last updated',
example: '2023-01-01T00:00:00.000Z',
})
updated_at?: Date | null;
}

View File

@@ -0,0 +1,97 @@
import { Roles } from '@/common/decorators';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateMunicipalityDto } from './dto/create-municipality.dto';
import { UpdateMunicipalityDto } from './dto/update-municipality.dto';
import { Municipality } from './entities/municipality.entity';
import { MunicipalitiesService } from './municipalities.service';
@ApiTags('Municipalities')
@Controller('configurations/municipalities')
export class MunicipalitiesController {
constructor(private readonly municipalitiesService: MunicipalitiesService) {}
@Get()
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get all municipalities' })
@ApiResponse({
status: 200,
description: 'Return all municipalities',
type: [Municipality],
})
findAll() {
return this.municipalitiesService.findAll();
}
@Get(':id')
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get a municipality by id' })
@ApiResponse({
status: 200,
description: 'Return a municipality',
type: Municipality,
})
@ApiResponse({ status: 404, description: 'Municipality not found' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.municipalitiesService.findOne(id);
}
@Get('state/:stateId')
@Roles('admin', 'user')
@ApiOperation({ summary: 'Get municipalities by state id' })
@ApiResponse({
status: 200,
description: 'Return municipalities by state',
type: [Municipality],
})
@ApiResponse({ status: 404, description: 'State not found' })
findByState(@Param('stateId', ParseIntPipe) stateId: number) {
return this.municipalitiesService.findByState(stateId);
}
@Post()
@Roles('ADMIN')
@ApiOperation({ summary: 'Create a new municipality' })
@ApiResponse({
status: 201,
description: 'Municipality created',
type: Municipality,
})
create(@Body() createMunicipalityDto: CreateMunicipalityDto) {
return this.municipalitiesService.create(createMunicipalityDto);
}
@Patch(':id')
@Roles('ADMIN')
@ApiOperation({ summary: 'Update a municipality' })
@ApiResponse({
status: 200,
description: 'Municipality updated',
type: Municipality,
})
@ApiResponse({ status: 404, description: 'Municipality not found' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateMunicipalityDto: UpdateMunicipalityDto,
) {
return this.municipalitiesService.update(id, updateMunicipalityDto);
}
@Delete(':id')
@Roles('ADMIN')
@ApiOperation({ summary: 'Delete a municipality' })
@ApiResponse({ status: 200, description: 'Municipality deleted' })
@ApiResponse({ status: 404, description: 'Municipality not found' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.municipalitiesService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { DrizzleModule } from '@/database/drizzle.module';
import { Module } from '@nestjs/common';
import { StatesModule } from '../states/states.module';
import { MunicipalitiesController } from './municipalities.controller';
import { MunicipalitiesService } from './municipalities.service';
@Module({
imports: [DrizzleModule, StatesModule],
controllers: [MunicipalitiesController],
providers: [MunicipalitiesService],
exports: [MunicipalitiesService],
})
export class MunicipalitiesModule {}

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