commit 475e0754df96c2fa712928c14ca827bb9099624f Author: Sergio Ramirez Date: Mon Jun 16 12:02:22 2025 -0400 base con autenticacion, registro, modulo encuestas diff --git a/.commitlintrc.ts b/.commitlintrc.ts new file mode 100644 index 0000000..3f5e287 --- /dev/null +++ b/.commitlintrc.ts @@ -0,0 +1 @@ +export default { extends: ['@commitlint/config-conventional'] }; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cae925f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..f976c0c --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +# #!/usr/bin/env sh +# . "$(dirname -- "$0")/_/husky.sh" + +# npx commitlint --edit $1 \ No newline at end of file diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 0000000..00ad986 --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,3 @@ +{ + "*.{js,jsx,ts,tsx}": ["prettier . --write"] +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e69de29 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5d6d342 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +pnpm-lock.yaml +.gitignore +.idea +.turbo +.changeset +pnpm-workspace.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e1ff765 --- /dev/null +++ b/.prettierrc @@ -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"] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f701435 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2111535 --- /dev/null +++ b/README.md @@ -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 + diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..0e8021f --- /dev/null +++ b/apps/api/.env.example @@ -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= diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..4b56acf --- /dev/null +++ b/apps/api/.gitignore @@ -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 diff --git a/apps/api/.swcrc b/apps/api/.swcrc new file mode 100644 index 0000000..09bf764 --- /dev/null +++ b/apps/api/.swcrc @@ -0,0 +1,13 @@ +{ + "$schema": "https://swc.rs/schema.json", + "sourceMaps": true, + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "baseUrl": "./" + }, + "minify": false +} diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..55b6565 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1 @@ +### Backend diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..2fec3a5 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,4 @@ +import { nestJsConfig } from '@repo/eslint-config/nest-js'; + +/** @type {import("eslint").Linter.Config} */ +export default nestJsConfig; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..579ca35 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "builder": "swc", + "typeCheck": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..cc08ae0 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..ac53a17 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 0000000..f67d416 --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -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}`); + }); +}; diff --git a/apps/api/src/common/config/envs.ts b/apps/api/src/common/config/envs.ts new file mode 100644 index 0000000..511490d --- /dev/null +++ b/apps/api/src/common/config/envs.ts @@ -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 +}; diff --git a/apps/api/src/common/constants/index.ts b/apps/api/src/common/constants/index.ts new file mode 100644 index 0000000..efbebd0 --- /dev/null +++ b/apps/api/src/common/constants/index.ts @@ -0,0 +1 @@ +export * from './role'; diff --git a/apps/api/src/common/constants/role.ts b/apps/api/src/common/constants/role.ts new file mode 100644 index 0000000..ed80175 --- /dev/null +++ b/apps/api/src/common/constants/role.ts @@ -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; diff --git a/apps/api/src/common/decorators/index.ts b/apps/api/src/common/decorators/index.ts new file mode 100644 index 0000000..ab100fe --- /dev/null +++ b/apps/api/src/common/decorators/index.ts @@ -0,0 +1,3 @@ +export * from './public.decorator'; +export * from './roles.decorator'; +export * from './user.decorator'; diff --git a/apps/api/src/common/decorators/permissions.decorator.ts b/apps/api/src/common/decorators/permissions.decorator.ts new file mode 100644 index 0000000..9b78dd2 --- /dev/null +++ b/apps/api/src/common/decorators/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); \ No newline at end of file diff --git a/apps/api/src/common/decorators/public.decorator.ts b/apps/api/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/api/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/common/decorators/require-permissions.decorator.ts b/apps/api/src/common/decorators/require-permissions.decorator.ts new file mode 100644 index 0000000..a6d5d97 --- /dev/null +++ b/apps/api/src/common/decorators/require-permissions.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions); \ No newline at end of file diff --git a/apps/api/src/common/decorators/roles.decorator.ts b/apps/api/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..23aae81 --- /dev/null +++ b/apps/api/src/common/decorators/roles.decorator.ts @@ -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); diff --git a/apps/api/src/common/decorators/user.decorator.ts b/apps/api/src/common/decorators/user.decorator.ts new file mode 100644 index 0000000..69bdbaa --- /dev/null +++ b/apps/api/src/common/decorators/user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/common/dto/pagination.dto.ts b/apps/api/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..b204537 --- /dev/null +++ b/apps/api/src/common/dto/pagination.dto.ts @@ -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'; +} \ No newline at end of file diff --git a/apps/api/src/common/guards/index.ts b/apps/api/src/common/guards/index.ts new file mode 100644 index 0000000..e174be2 --- /dev/null +++ b/apps/api/src/common/guards/index.ts @@ -0,0 +1,2 @@ +export * from './jwt-auth.guard'; +export * from './roles.guard'; diff --git a/apps/api/src/common/guards/jwt-auth.guard.ts b/apps/api/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..279694b --- /dev/null +++ b/apps/api/src/common/guards/jwt-auth.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(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; + } +} diff --git a/apps/api/src/common/guards/jwt-refresh.guard.ts b/apps/api/src/common/guards/jwt-refresh.guard.ts new file mode 100644 index 0000000..2a31486 --- /dev/null +++ b/apps/api/src/common/guards/jwt-refresh.guard.ts @@ -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, + ) {} + + async canActivate(context: ExecutionContext): Promise { + 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; + } +} diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..474c655 --- /dev/null +++ b/apps/api/src/common/guards/roles.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const requiredRoles = this.reflector.getAllAndOverride(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) + ); + } +} diff --git a/apps/api/src/common/interceptors/index.ts b/apps/api/src/common/interceptors/index.ts new file mode 100644 index 0000000..3cc9876 --- /dev/null +++ b/apps/api/src/common/interceptors/index.ts @@ -0,0 +1 @@ +export * from './req-log.interceptor'; diff --git a/apps/api/src/common/interceptors/req-log.interceptor.ts b/apps/api/src/common/interceptors/req-log.interceptor.ts new file mode 100644 index 0000000..17646ee --- /dev/null +++ b/apps/api/src/common/interceptors/req-log.interceptor.ts @@ -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 { + 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]), + ), + ), + ); + } +} diff --git a/apps/api/src/common/middlewares/index.ts b/apps/api/src/common/middlewares/index.ts new file mode 100644 index 0000000..6eda966 --- /dev/null +++ b/apps/api/src/common/middlewares/index.ts @@ -0,0 +1 @@ +export * from './logger.middleware'; diff --git a/apps/api/src/common/middlewares/logger.middleware.ts b/apps/api/src/common/middlewares/logger.middleware.ts new file mode 100644 index 0000000..9774457 --- /dev/null +++ b/apps/api/src/common/middlewares/logger.middleware.ts @@ -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(); + } +} diff --git a/apps/api/src/common/modules/index.ts b/apps/api/src/common/modules/index.ts new file mode 100644 index 0000000..523ba2b --- /dev/null +++ b/apps/api/src/common/modules/index.ts @@ -0,0 +1,3 @@ +export * from './logger.module'; +export * from './node-mailer.module'; +export * from './throttle.module'; diff --git a/apps/api/src/common/modules/logger.module.ts b/apps/api/src/common/modules/logger.module.ts new file mode 100644 index 0000000..8e195a7 --- /dev/null +++ b/apps/api/src/common/modules/logger.module.ts @@ -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) => ({ + pinoHttp: { + quietReqLogger: false, + quietResLogger: false, + transport: { + target: + config.get('NODE_ENV') !== 'production' ? 'pino-pretty' : '', + }, + }, + }), + }), + ], +}) +export class LoggerModule {} diff --git a/apps/api/src/common/modules/node-mailer.module.ts b/apps/api/src/common/modules/node-mailer.module.ts new file mode 100644 index 0000000..8ac82d4 --- /dev/null +++ b/apps/api/src/common/modules/node-mailer.module.ts @@ -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) => ({ + transport: { + service: config.get('MAIL_HOST'), + auth: { + user: config.get('MAIL_USERNAME'), + pass: config.get('MAIL_PASSWORD'), + }, + }, + }), + }), + ], +}) +export class NodeMailerModule {} diff --git a/apps/api/src/common/modules/throttle.module.ts b/apps/api/src/common/modules/throttle.module.ts new file mode 100644 index 0000000..092eebc --- /dev/null +++ b/apps/api/src/common/modules/throttle.module.ts @@ -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 {} diff --git a/apps/api/src/common/pipes/file-size-validator.pipe.ts b/apps/api/src/common/pipes/file-size-validator.pipe.ts new file mode 100644 index 0000000..cd351a4 --- /dev/null +++ b/apps/api/src/common/pipes/file-size-validator.pipe.ts @@ -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 + ); + } +} diff --git a/apps/api/src/common/pipes/file-type-validator.pipe.ts b/apps/api/src/common/pipes/file-type-validator.pipe.ts new file mode 100644 index 0000000..99208a9 --- /dev/null +++ b/apps/api/src/common/pipes/file-type-validator.pipe.ts @@ -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) + ); + } +} diff --git a/apps/api/src/common/pipes/index.ts b/apps/api/src/common/pipes/index.ts new file mode 100644 index 0000000..a9959ba --- /dev/null +++ b/apps/api/src/common/pipes/index.ts @@ -0,0 +1,3 @@ +export * from './file-size-validator.pipe'; +export * from './file-type-validator.pipe'; +export * from './zod-validator.pipe'; diff --git a/apps/api/src/common/pipes/zod-validator.pipe.ts b/apps/api/src/common/pipes/zod-validator.pipe.ts new file mode 100644 index 0000000..7ca2bdd --- /dev/null +++ b/apps/api/src/common/pipes/zod-validator.pipe.ts @@ -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 { + const validateFields = this.schema.safeParse(value); + if (!validateFields.success) + throw new BadRequestException({ + errors: validateFields.error.flatten().fieldErrors, + }); + return validateFields.data; + } +} diff --git a/apps/api/src/common/utils/bcrypt.ts b/apps/api/src/common/utils/bcrypt.ts new file mode 100644 index 0000000..620b865 --- /dev/null +++ b/apps/api/src/common/utils/bcrypt.ts @@ -0,0 +1,16 @@ +import * as bcrypt from 'bcryptjs'; +import { compare, hash } from 'bcryptjs'; + +const hashString = async (password: string): Promise => { + const salt = await bcrypt.genSalt(10); + return hash(password, salt); +}; + +const validateString = async ( + plainPassword: string, + hashedPassword: string, +): Promise => { + return await compare(plainPassword, hashedPassword); +}; + +export { hashString, validateString }; diff --git a/apps/api/src/common/utils/dateTimeUtility.ts b/apps/api/src/common/utils/dateTimeUtility.ts new file mode 100644 index 0000000..0d5e68f --- /dev/null +++ b/apps/api/src/common/utils/dateTimeUtility.ts @@ -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; +}; diff --git a/apps/api/src/common/utils/index.ts b/apps/api/src/common/utils/index.ts new file mode 100644 index 0000000..acbe6a8 --- /dev/null +++ b/apps/api/src/common/utils/index.ts @@ -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'; diff --git a/apps/api/src/common/utils/validateEnv.ts b/apps/api/src/common/utils/validateEnv.ts new file mode 100644 index 0000000..2a08526 --- /dev/null +++ b/apps/api/src/common/utils/validateEnv.ts @@ -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; + +export const validateEnv = (config: Record): Env => { + const validate = EnvSchema.safeParse(config); + if (!validate.success) { + throw new Error(validate.error.message); + } + return validate.data; +}; diff --git a/apps/api/src/database/drizzle-provider.ts b/apps/api/src/database/drizzle-provider.ts new file mode 100644 index 0000000..2d7a601 --- /dev/null +++ b/apps/api/src/database/drizzle-provider.ts @@ -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; + +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; + }, +}; diff --git a/apps/api/src/database/drizzle.module.ts b/apps/api/src/database/drizzle.module.ts new file mode 100644 index 0000000..eed98e0 --- /dev/null +++ b/apps/api/src/database/drizzle.module.ts @@ -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 {} diff --git a/apps/api/src/database/index.ts b/apps/api/src/database/index.ts new file mode 100644 index 0000000..e6715c9 --- /dev/null +++ b/apps/api/src/database/index.ts @@ -0,0 +1,5 @@ + +export * from './schema/activity_logs'; +export * from './schema/auth'; +export * from './schema/general'; +export * from './schema/surveys' diff --git a/apps/api/src/database/migrations/0000_abnormal_lethal_legion.sql b/apps/api/src/database/migrations/0000_abnormal_lethal_legion.sql new file mode 100644 index 0000000..4fa199d --- /dev/null +++ b/apps/api/src/database/migrations/0000_abnormal_lethal_legion.sql @@ -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); \ No newline at end of file diff --git a/apps/api/src/database/migrations/0001_massive_kylun.sql b/apps/api/src/database/migrations/0001_massive_kylun.sql new file mode 100644 index 0000000..a9e65de --- /dev/null +++ b/apps/api/src/database/migrations/0001_massive_kylun.sql @@ -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); \ No newline at end of file diff --git a/apps/api/src/database/migrations/meta/0000_snapshot.json b/apps/api/src/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..5e88c5a --- /dev/null +++ b/apps/api/src/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,1306 @@ +{ + "id": "317d79fd-7de3-4657-8265-11ddb34b0189", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activityLogs_idx": { + "name": "activityLogs_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_logs_user_id_users_id_fk": { + "name": "activity_logs_user_id_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.roles": { + "name": "roles", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "roles_idx": { + "name": "roles_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_idx": { + "name": "sessions_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_two_factor_enabled": { + "name": "is_two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_idx": { + "name": "users_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user_role": { + "name": "user_role", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_role_idx": { + "name": "user_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_users_id_fk": { + "name": "user_role_user_id_users_id_fk", + "tableFrom": "user_role", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_roles_id_fk": { + "name": "user_role_role_id_roles_id_fk", + "tableFrom": "user_role", + "tableTo": "roles", + "schemaTo": "auth", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verificationToken": { + "name": "verificationToken", + "schema": "auth", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_type": { + "name": "category_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "group": { + "name": "group", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "category_typeIx0": { + "name": "category_typeIx0", + "columns": [ + { + "expression": "group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "category_typeIx1": { + "name": "category_typeIx1", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.localities": { + "name": "localities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parish_id": { + "name": "parish_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "localities_index_03": { + "name": "localities_index_03", + "columns": [ + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parish_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_00": { + "name": "localities_index_00", + "columns": [ + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_01": { + "name": "localities_index_01", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_02": { + "name": "localities_index_02", + "columns": [ + { + "expression": "parish_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "localities_state_id_states_id_fk": { + "name": "localities_state_id_states_id_fk", + "tableFrom": "localities", + "tableTo": "states", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "localities_municipality_id_municipalities_id_fk": { + "name": "localities_municipality_id_municipalities_id_fk", + "tableFrom": "localities", + "tableTo": "municipalities", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "localities_parish_id_parishes_id_fk": { + "name": "localities_parish_id_parishes_id_fk", + "tableFrom": "localities", + "tableTo": "parishes", + "columnsFrom": [ + "parish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "localities_name_unique": { + "name": "localities_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipalities": { + "name": "municipalities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "municipalities_index_00": { + "name": "municipalities_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipalities_state_id_states_id_fk": { + "name": "municipalities_state_id_states_id_fk", + "tableFrom": "municipalities", + "tableTo": "states", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parishes": { + "name": "parishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "parishes_index_00": { + "name": "parishes_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parishes_municipality_id_municipalities_id_fk": { + "name": "parishes_municipality_id_municipalities_id_fk", + "tableFrom": "parishes", + "tableTo": "municipalities", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.states": { + "name": "states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "states_index_00": { + "name": "states_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.answers_surveys": { + "name": "answers_surveys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "answers_index_00": { + "name": "answers_index_00", + "columns": [ + { + "expression": "answers", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "answers_index_01": { + "name": "answers_index_01", + "columns": [ + { + "expression": "survey_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "answers_index_02": { + "name": "answers_index_02", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "answers_surveys_survey_id_surveys_id_fk": { + "name": "answers_surveys_survey_id_surveys_id_fk", + "tableFrom": "answers_surveys", + "tableTo": "surveys", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "answers_surveys_user_id_users_id_fk": { + "name": "answers_surveys_user_id_users_id_fk", + "tableFrom": "answers_surveys", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.surveys": { + "name": "surveys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_audience": { + "name": "target_audience", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "closing_date": { + "name": "closing_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "questions": { + "name": "questions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "surveys_index_00": { + "name": "surveys_index_00", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "auth.gender": { + "name": "gender", + "schema": "auth", + "values": [ + "FEMENINO", + "MASCULINO" + ] + }, + "public.nationality": { + "name": "nationality", + "schema": "public", + "values": [ + "VENEZOLANO", + "EXTRANJERO" + ] + }, + "auth.status": { + "name": "status", + "schema": "auth", + "values": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "auth.user_access_view": { + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n u.id AS user_id,\n u.username,\n u.email,\n u.fullname,\n r.id AS role_id,\n r.name AS role_name\nFROM\n auth.users u\nLEFT JOIN\n auth.user_role ur ON u.id = ur.user_id \nLEFT JOIN\n auth.roles r ON ur.role_id = r.id", + "name": "user_access_view", + "schema": "auth", + "isExisting": false, + "materialized": false + }, + "public.v_surveys": { + "columns": { + "survey_id": { + "name": "survey_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closing_date": { + "name": "closing_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "target_audience": { + "name": "target_audience", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "definition": "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\nleft join answers_surveys as2 on as2.survey_id = s.id\nwhere s.published = true", + "name": "v_surveys", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/database/migrations/meta/0001_snapshot.json b/apps/api/src/database/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..8ea460c --- /dev/null +++ b/apps/api/src/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,1358 @@ +{ + "id": "cdbb3495-688f-4b2d-ab8e-e62e42328fd5", + "prevId": "317d79fd-7de3-4657-8265-11ddb34b0189", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activityLogs_idx": { + "name": "activityLogs_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_logs_user_id_users_id_fk": { + "name": "activity_logs_user_id_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.roles": { + "name": "roles", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "roles_idx": { + "name": "roles_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_idx": { + "name": "sessions_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "municipality": { + "name": "municipality", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parish": { + "name": "parish", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_two_factor_enabled": { + "name": "is_two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_idx": { + "name": "users_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_state_states_id_fk": { + "name": "users_state_states_id_fk", + "tableFrom": "users", + "tableTo": "states", + "columnsFrom": [ + "state" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "users_municipality_municipalities_id_fk": { + "name": "users_municipality_municipalities_id_fk", + "tableFrom": "users", + "tableTo": "municipalities", + "columnsFrom": [ + "municipality" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "users_parish_parishes_id_fk": { + "name": "users_parish_parishes_id_fk", + "tableFrom": "users", + "tableTo": "parishes", + "columnsFrom": [ + "parish" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user_role": { + "name": "user_role", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_role_idx": { + "name": "user_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_users_id_fk": { + "name": "user_role_user_id_users_id_fk", + "tableFrom": "user_role", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_roles_id_fk": { + "name": "user_role_role_id_roles_id_fk", + "tableFrom": "user_role", + "tableTo": "roles", + "schemaTo": "auth", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verificationToken": { + "name": "verificationToken", + "schema": "auth", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_type": { + "name": "category_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "group": { + "name": "group", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "category_typeIx0": { + "name": "category_typeIx0", + "columns": [ + { + "expression": "group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "category_typeIx1": { + "name": "category_typeIx1", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.localities": { + "name": "localities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parish_id": { + "name": "parish_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "localities_index_03": { + "name": "localities_index_03", + "columns": [ + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parish_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_00": { + "name": "localities_index_00", + "columns": [ + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_01": { + "name": "localities_index_01", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "localities_index_02": { + "name": "localities_index_02", + "columns": [ + { + "expression": "parish_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "localities_state_id_states_id_fk": { + "name": "localities_state_id_states_id_fk", + "tableFrom": "localities", + "tableTo": "states", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "localities_municipality_id_municipalities_id_fk": { + "name": "localities_municipality_id_municipalities_id_fk", + "tableFrom": "localities", + "tableTo": "municipalities", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "localities_parish_id_parishes_id_fk": { + "name": "localities_parish_id_parishes_id_fk", + "tableFrom": "localities", + "tableTo": "parishes", + "columnsFrom": [ + "parish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "localities_name_unique": { + "name": "localities_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipalities": { + "name": "municipalities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "municipalities_index_00": { + "name": "municipalities_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipalities_state_id_states_id_fk": { + "name": "municipalities_state_id_states_id_fk", + "tableFrom": "municipalities", + "tableTo": "states", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parishes": { + "name": "parishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "parishes_index_00": { + "name": "parishes_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parishes_municipality_id_municipalities_id_fk": { + "name": "parishes_municipality_id_municipalities_id_fk", + "tableFrom": "parishes", + "tableTo": "municipalities", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.states": { + "name": "states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "states_index_00": { + "name": "states_index_00", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.answers_surveys": { + "name": "answers_surveys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "answers_index_00": { + "name": "answers_index_00", + "columns": [ + { + "expression": "answers", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "answers_index_01": { + "name": "answers_index_01", + "columns": [ + { + "expression": "survey_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "answers_index_02": { + "name": "answers_index_02", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "answers_surveys_survey_id_surveys_id_fk": { + "name": "answers_surveys_survey_id_surveys_id_fk", + "tableFrom": "answers_surveys", + "tableTo": "surveys", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "answers_surveys_user_id_users_id_fk": { + "name": "answers_surveys_user_id_users_id_fk", + "tableFrom": "answers_surveys", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.surveys": { + "name": "surveys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_audience": { + "name": "target_audience", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "closing_date": { + "name": "closing_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "questions": { + "name": "questions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "surveys_index_00": { + "name": "surveys_index_00", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "auth.gender": { + "name": "gender", + "schema": "auth", + "values": [ + "FEMENINO", + "MASCULINO" + ] + }, + "public.nationality": { + "name": "nationality", + "schema": "public", + "values": [ + "VENEZOLANO", + "EXTRANJERO" + ] + }, + "auth.status": { + "name": "status", + "schema": "auth", + "values": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "auth.user_access_view": { + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n u.id AS user_id,\n u.username,\n u.email,\n u.fullname,\n r.id AS role_id,\n r.name AS role_name\nFROM\n auth.users u\nLEFT JOIN\n auth.user_role ur ON u.id = ur.user_id \nLEFT JOIN\n auth.roles r ON ur.role_id = r.id", + "name": "user_access_view", + "schema": "auth", + "isExisting": false, + "materialized": false + }, + "public.v_surveys": { + "columns": { + "survey_id": { + "name": "survey_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closing_date": { + "name": "closing_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "target_audience": { + "name": "target_audience", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select id as survey_id, title, description, created_at, closing_date, target_audience from surveys\nwhere published = true", + "name": "v_surveys", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/database/migrations/meta/_journal.json b/apps/api/src/database/migrations/meta/_journal.json new file mode 100644 index 0000000..0f874c3 --- /dev/null +++ b/apps/api/src/database/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/apps/api/src/database/schema/activity_logs.ts b/apps/api/src/database/schema/activity_logs.ts new file mode 100644 index 0000000..24c1a23 --- /dev/null +++ b/apps/api/src/database/schema/activity_logs.ts @@ -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), + }), +); diff --git a/apps/api/src/database/schema/auth.ts b/apps/api/src/database/schema/auth.ts new file mode 100644 index 0000000..626050b --- /dev/null +++ b/apps/api/src/database/schema/auth.ts @@ -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], + }), + }, + ], +); diff --git a/apps/api/src/database/schema/enum.ts b/apps/api/src/database/schema/enum.ts new file mode 100644 index 0000000..2e701da --- /dev/null +++ b/apps/api/src/database/schema/enum.ts @@ -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', +]); diff --git a/apps/api/src/database/schema/general.ts b/apps/api/src/database/schema/general.ts new file mode 100644 index 0000000..2acb580 --- /dev/null +++ b/apps/api/src/database/schema/general.ts @@ -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"), +// }); diff --git a/apps/api/src/database/schema/schemas.ts b/apps/api/src/database/schema/schemas.ts new file mode 100644 index 0000000..88b8bbd --- /dev/null +++ b/apps/api/src/database/schema/schemas.ts @@ -0,0 +1,4 @@ +//schemas +import * as t from 'drizzle-orm/pg-core'; +export const authSchema = t.pgSchema('auth'); //autenticacion y sessiones usuarios + diff --git a/apps/api/src/database/schema/surveys.ts b/apps/api/src/database/schema/surveys.ts new file mode 100644 index 0000000..ccbede3 --- /dev/null +++ b/apps/api/src/database/schema/surveys.ts @@ -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`); \ No newline at end of file diff --git a/apps/api/src/database/seeds/admin-role.seed.ts b/apps/api/src/database/seeds/admin-role.seed.ts new file mode 100644 index 0000000..16e3b2f --- /dev/null +++ b/apps/api/src/database/seeds/admin-role.seed.ts @@ -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) { + 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'); +} diff --git a/apps/api/src/database/seeds/index.ts b/apps/api/src/database/seeds/index.ts new file mode 100644 index 0000000..acbeab6 --- /dev/null +++ b/apps/api/src/database/seeds/index.ts @@ -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(); diff --git a/apps/api/src/database/seeds/municipalities.seed.ts b/apps/api/src/database/seeds/municipalities.seed.ts new file mode 100644 index 0000000..e1de996 --- /dev/null +++ b/apps/api/src/database/seeds/municipalities.seed.ts @@ -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) { + 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'); +} diff --git a/apps/api/src/database/seeds/parishes.seed.ts b/apps/api/src/database/seeds/parishes.seed.ts new file mode 100644 index 0000000..8fd28d8 --- /dev/null +++ b/apps/api/src/database/seeds/parishes.seed.ts @@ -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) { + 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'); +} diff --git a/apps/api/src/database/seeds/states.seed.ts b/apps/api/src/database/seeds/states.seed.ts new file mode 100644 index 0000000..79de885 --- /dev/null +++ b/apps/api/src/database/seeds/states.seed.ts @@ -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) { + 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'); +} diff --git a/apps/api/src/database/seeds/user-admin.seed.ts b/apps/api/src/database/seeds/user-admin.seed.ts new file mode 100644 index 0000000..dcc16de --- /dev/null +++ b/apps/api/src/database/seeds/user-admin.seed.ts @@ -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) { + + // 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); + } +} \ No newline at end of file diff --git a/apps/api/src/database/timestamps.ts b/apps/api/src/database/timestamps.ts new file mode 100644 index 0000000..2198878 --- /dev/null +++ b/apps/api/src/database/timestamps.ts @@ -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()), +}; \ No newline at end of file diff --git a/apps/api/src/drizzle.config.ts b/apps/api/src/drizzle.config.ts new file mode 100644 index 0000000..1e34e64 --- /dev/null +++ b/apps/api/src/drizzle.config.ts @@ -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; diff --git a/apps/api/src/features/auth/auth.controller.ts b/apps/api/src/features/auth/auth.controller.ts new file mode 100644 index 0000000..b856470 --- /dev/null +++ b/apps/api/src/features/auth/auth.controller.ts @@ -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); + } + + +} diff --git a/apps/api/src/features/auth/auth.module.ts b/apps/api/src/features/auth/auth.module.ts new file mode 100644 index 0000000..aa9357c --- /dev/null +++ b/apps/api/src/features/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/features/auth/auth.service.ts b/apps/api/src/features/auth/auth.service.ts new file mode 100644 index 0000000..622fd01 --- /dev/null +++ b/apps/api/src/features/auth/auth.service.ts @@ -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, + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + 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 { + 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 { + return crypto + .randomInt(0, 10 ** length) + .toString() + .padStart(length, '0'); + } + + // metodo para crear una session + private async createSession(sessionInput: Session): Promise { + 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 { + const user = await this.drizzle + .select() + .from(users) + .where(eq(users.username, username)); + return user[0]; + } + + //Find User + async findUserById(id: number): Promise { + 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 { + 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 { + 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 { + + 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 { + // 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 { + 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 { + 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 { + // 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; + }) + + } +} diff --git a/apps/api/src/features/auth/dto/change-password.dto.ts b/apps/api/src/features/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..f4eac52 --- /dev/null +++ b/apps/api/src/features/auth/dto/change-password.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/confirm-email.dto.ts b/apps/api/src/features/auth/dto/confirm-email.dto.ts new file mode 100644 index 0000000..467d704 --- /dev/null +++ b/apps/api/src/features/auth/dto/confirm-email.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/create-user.dto.ts b/apps/api/src/features/auth/dto/create-user.dto.ts new file mode 100644 index 0000000..0c70d6d --- /dev/null +++ b/apps/api/src/features/auth/dto/create-user.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/forgot-password.dto.ts b/apps/api/src/features/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..62a583d --- /dev/null +++ b/apps/api/src/features/auth/dto/forgot-password.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/refresh-token.dto.ts b/apps/api/src/features/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..dd2e8c1 --- /dev/null +++ b/apps/api/src/features/auth/dto/refresh-token.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/reset-password.dto.ts b/apps/api/src/features/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..9c1a38d --- /dev/null +++ b/apps/api/src/features/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/signIn-user.dto.ts b/apps/api/src/features/auth/dto/signIn-user.dto.ts new file mode 100644 index 0000000..ea186c0 --- /dev/null +++ b/apps/api/src/features/auth/dto/signIn-user.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/signOut-user.dto.ts b/apps/api/src/features/auth/dto/signOut-user.dto.ts new file mode 100644 index 0000000..a1b6d38 --- /dev/null +++ b/apps/api/src/features/auth/dto/signOut-user.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/signUp-user.dto.ts b/apps/api/src/features/auth/dto/signUp-user.dto.ts new file mode 100644 index 0000000..20f9c5f --- /dev/null +++ b/apps/api/src/features/auth/dto/signUp-user.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/dto/update-refresh-token.dto.ts b/apps/api/src/features/auth/dto/update-refresh-token.dto.ts new file mode 100644 index 0000000..7e9e435 --- /dev/null +++ b/apps/api/src/features/auth/dto/update-refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { User } from '@/features/users/entities/user.entity'; + +export class UpdateRefreshTokenDto { + user: User; + refresh_token: string; +} diff --git a/apps/api/src/features/auth/dto/validate-user.dto.ts b/apps/api/src/features/auth/dto/validate-user.dto.ts new file mode 100644 index 0000000..7233789 --- /dev/null +++ b/apps/api/src/features/auth/dto/validate-user.dto.ts @@ -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; +} diff --git a/apps/api/src/features/auth/interfaces/auth-tokens.interface.ts b/apps/api/src/features/auth/interfaces/auth-tokens.interface.ts new file mode 100644 index 0000000..f5f5b17 --- /dev/null +++ b/apps/api/src/features/auth/interfaces/auth-tokens.interface.ts @@ -0,0 +1,6 @@ +interface AuthTokensInterface { + access_token: string; + refresh_token: string; +} + +export default AuthTokensInterface; diff --git a/apps/api/src/features/auth/interfaces/login-user.interface.ts b/apps/api/src/features/auth/interfaces/login-user.interface.ts new file mode 100644 index 0000000..79c9622 --- /dev/null +++ b/apps/api/src/features/auth/interfaces/login-user.interface.ts @@ -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; +} diff --git a/apps/api/src/features/auth/interfaces/refresh-token.interface.ts b/apps/api/src/features/auth/interfaces/refresh-token.interface.ts new file mode 100644 index 0000000..9afeb6a --- /dev/null +++ b/apps/api/src/features/auth/interfaces/refresh-token.interface.ts @@ -0,0 +1,8 @@ +interface RefreshTokenInterface { + access_token: string; + access_expire_in: number; + refresh_token: string; + refresh_expire_in: number; +} + +export default RefreshTokenInterface; diff --git a/apps/api/src/features/auth/interfaces/session.interface.ts b/apps/api/src/features/auth/interfaces/session.interface.ts new file mode 100644 index 0000000..d571c92 --- /dev/null +++ b/apps/api/src/features/auth/interfaces/session.interface.ts @@ -0,0 +1,5 @@ +export interface Session { + userId: string; + sessionToken: string; + expiresAt: number; +} diff --git a/apps/api/src/features/configurations/category-types/category-types.controller.ts b/apps/api/src/features/configurations/category-types/category-types.controller.ts new file mode 100644 index 0000000..9c42b99 --- /dev/null +++ b/apps/api/src/features/configurations/category-types/category-types.controller.ts @@ -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); + } +} diff --git a/apps/api/src/features/configurations/category-types/category-types.module.ts b/apps/api/src/features/configurations/category-types/category-types.module.ts new file mode 100644 index 0000000..2a1127a --- /dev/null +++ b/apps/api/src/features/configurations/category-types/category-types.module.ts @@ -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 {} diff --git a/apps/api/src/features/configurations/category-types/category-types.service.ts b/apps/api/src/features/configurations/category-types/category-types.service.ts new file mode 100644 index 0000000..f114d08 --- /dev/null +++ b/apps/api/src/features/configurations/category-types/category-types.service.ts @@ -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, + ) {} + + async findAll(): Promise { + return await this.drizzle.select().from(categoryType); + } + + async findOne(id: number): Promise { + 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 { + return await this.drizzle + .select() + .from(categoryType) + .where(eq(categoryType.group, group)); + } + + async create( + createCategoryTypeDto: CreateCategoryTypeDto, + ): Promise { + const [category] = await this.drizzle + .insert(categoryType) + .values({ + group: createCategoryTypeDto.group, + description: createCategoryTypeDto.description, + }) + .returning(); + + return category; + } + + async update( + id: number, + updateCategoryTypeDto: UpdateCategoryTypeDto, + ): Promise { + // 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' }; + } +} diff --git a/apps/api/src/features/configurations/category-types/dto/create-category-type.dto.ts b/apps/api/src/features/configurations/category-types/dto/create-category-type.dto.ts new file mode 100644 index 0000000..c9c06aa --- /dev/null +++ b/apps/api/src/features/configurations/category-types/dto/create-category-type.dto.ts @@ -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; + +} \ No newline at end of file diff --git a/apps/api/src/features/configurations/category-types/dto/update-category-type.dto.ts b/apps/api/src/features/configurations/category-types/dto/update-category-type.dto.ts new file mode 100644 index 0000000..0b9d2aa --- /dev/null +++ b/apps/api/src/features/configurations/category-types/dto/update-category-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCategoryTypeDto } from './create-category-type.dto'; + +export class UpdateCategoryTypeDto extends PartialType(CreateCategoryTypeDto) {} \ No newline at end of file diff --git a/apps/api/src/features/configurations/category-types/entities/category-type.entity.ts b/apps/api/src/features/configurations/category-types/entities/category-type.entity.ts new file mode 100644 index 0000000..2d9b268 --- /dev/null +++ b/apps/api/src/features/configurations/category-types/entities/category-type.entity.ts @@ -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; +} diff --git a/apps/api/src/features/configurations/configurations.module.ts b/apps/api/src/features/configurations/configurations.module.ts new file mode 100644 index 0000000..62db91b --- /dev/null +++ b/apps/api/src/features/configurations/configurations.module.ts @@ -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 {} diff --git a/apps/api/src/features/configurations/municipalities/dto/create-municipality.dto.ts b/apps/api/src/features/configurations/municipalities/dto/create-municipality.dto.ts new file mode 100644 index 0000000..707206b --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/dto/create-municipality.dto.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/features/configurations/municipalities/dto/update-municipality.dto.ts b/apps/api/src/features/configurations/municipalities/dto/update-municipality.dto.ts new file mode 100644 index 0000000..459d32c --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/dto/update-municipality.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateMunicipalityDto } from './create-municipality.dto'; + +export class UpdateMunicipalityDto extends PartialType(CreateMunicipalityDto) {} \ No newline at end of file diff --git a/apps/api/src/features/configurations/municipalities/entities/municipality.entity.ts b/apps/api/src/features/configurations/municipalities/entities/municipality.entity.ts new file mode 100644 index 0000000..2b547be --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/entities/municipality.entity.ts @@ -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; +} diff --git a/apps/api/src/features/configurations/municipalities/municipalities.controller.ts b/apps/api/src/features/configurations/municipalities/municipalities.controller.ts new file mode 100644 index 0000000..3a0e02f --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/municipalities.controller.ts @@ -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); + } +} diff --git a/apps/api/src/features/configurations/municipalities/municipalities.module.ts b/apps/api/src/features/configurations/municipalities/municipalities.module.ts new file mode 100644 index 0000000..77048b3 --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/municipalities.module.ts @@ -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 {} diff --git a/apps/api/src/features/configurations/municipalities/municipalities.service.ts b/apps/api/src/features/configurations/municipalities/municipalities.service.ts new file mode 100644 index 0000000..ff14acd --- /dev/null +++ b/apps/api/src/features/configurations/municipalities/municipalities.service.ts @@ -0,0 +1,120 @@ +import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; +import * as schema from '@/database/index'; +import { municipalities, states } 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 { StatesService } from '../states/states.service'; +import { CreateMunicipalityDto } from './dto/create-municipality.dto'; +import { UpdateMunicipalityDto } from './dto/update-municipality.dto'; +import { Municipality } from './entities/municipality.entity'; + +@Injectable() +export class MunicipalitiesService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + private statesService: StatesService, + ) {} + + async findAll(): Promise { + return await this.drizzle + .select({ + id: municipalities.id, + name: municipalities.name, + stateId: municipalities.stateId, + stateName: states.name, + created_at: municipalities.created_at, + updated_at: municipalities.updated_at, + }) + .from(municipalities) + .leftJoin(states, eq(municipalities.stateId, states.id)); + } + + async findOne(id: number): Promise { + const municipality = await this.drizzle + .select({ + id: municipalities.id, + name: municipalities.name, + stateId: municipalities.stateId, + stateName: states.name, + created_at: municipalities.created_at, + updated_at: municipalities.updated_at, + }) + .from(municipalities) + .leftJoin(states, eq(municipalities.stateId, states.id)) + .where(eq(municipalities.id, id)); + + if (municipality.length === 0) { + throw new HttpException('Municipality not found', HttpStatus.NOT_FOUND); + } + + return municipality[0]; + } + + async findByState(stateId: number): Promise { + // Verify state exists + await this.statesService.findOne(stateId); + + return await this.drizzle + .select({ + id: municipalities.id, + name: municipalities.name, + stateId: municipalities.stateId, + stateName: states.name, + created_at: municipalities.created_at, + updated_at: municipalities.updated_at, + }) + .from(municipalities) + .leftJoin(states, eq(municipalities.stateId, states.id)) + .where(eq(municipalities.stateId, stateId)); + } + + async create( + createMunicipalityDto: CreateMunicipalityDto, + ): Promise { + // Verify state exists + await this.statesService.findOne(createMunicipalityDto.stateId); + + const [municipality] = await this.drizzle + .insert(municipalities) + .values({ + name: createMunicipalityDto.name, + stateId: createMunicipalityDto.stateId, + }) + .returning(); + + return this.findOne(municipality.id); + } + + async update( + id: number, + updateMunicipalityDto: UpdateMunicipalityDto, + ): Promise { + // Check if municipality exists + await this.findOne(id); + + // If stateId is provided, verify it exists + if (updateMunicipalityDto.stateId) { + await this.statesService.findOne(updateMunicipalityDto.stateId); + } + + await this.drizzle + .update(municipalities) + .set({ + name: updateMunicipalityDto.name, + stateId: updateMunicipalityDto.stateId, + }) + .where(eq(municipalities.id, id)); + + return this.findOne(id); + } + + async remove(id: number): Promise<{ message: string }> { + // Check if municipality exists + await this.findOne(id); + + await this.drizzle.delete(municipalities).where(eq(municipalities.id, id)); + + return { message: 'Municipality deleted successfully' }; + } +} diff --git a/apps/api/src/features/configurations/parishes/dto/create-parish.dto.ts b/apps/api/src/features/configurations/parishes/dto/create-parish.dto.ts new file mode 100644 index 0000000..e122d76 --- /dev/null +++ b/apps/api/src/features/configurations/parishes/dto/create-parish.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; + +export class CreateParishDto { + @ApiProperty({ + description: 'The name of the parish', + example: 'Downtown', + }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ + description: 'The ID of the municipality this parish belongs to', + example: 1, + }) + @IsNotEmpty() + @IsNumber() + municipalityId: number; +} \ No newline at end of file diff --git a/apps/api/src/features/configurations/parishes/dto/update-parish.dto.ts b/apps/api/src/features/configurations/parishes/dto/update-parish.dto.ts new file mode 100644 index 0000000..9d680ab --- /dev/null +++ b/apps/api/src/features/configurations/parishes/dto/update-parish.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateParishDto } from './create-parish.dto'; + +export class UpdateParishDto extends PartialType(CreateParishDto) {} \ No newline at end of file diff --git a/apps/api/src/features/configurations/parishes/entities/parish.entity.ts b/apps/api/src/features/configurations/parishes/entities/parish.entity.ts new file mode 100644 index 0000000..06c524c --- /dev/null +++ b/apps/api/src/features/configurations/parishes/entities/parish.entity.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class Parish { + @ApiProperty({ + description: 'The unique identifier of the parish', + example: 1, + }) + id: number; + + @ApiProperty({ + description: 'The name of the parish', + example: 'Downtown', + }) + name: string; + + @ApiProperty({ + description: 'The ID of the municipality this parish belongs to', + example: 1, + }) + municipalityId: number; + + @ApiProperty({ + description: 'The name of the municipality this parish belongs to', + example: 'Los Angeles', + }) + municipalityName?: string | null; + + @ApiProperty({ + description: 'The date when the parish was created', + example: '2023-01-01T00:00:00.000Z', + }) + created_at?: Date | null; + + @ApiProperty({ + description: 'The date when the parish was last updated', + example: '2023-01-01T00:00:00.000Z', + }) + updated_at: Date | null; +} diff --git a/apps/api/src/features/configurations/parishes/parishes.controller.ts b/apps/api/src/features/configurations/parishes/parishes.controller.ts new file mode 100644 index 0000000..4f690b3 --- /dev/null +++ b/apps/api/src/features/configurations/parishes/parishes.controller.ts @@ -0,0 +1,99 @@ +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 { CreateParishDto } from './dto/create-parish.dto'; +import { UpdateParishDto } from './dto/update-parish.dto'; +import { Parish } from './entities/parish.entity'; +import { ParishesService } from './parishes.service'; + +@ApiTags('Parishes') +@Controller('configurations/parishes') +export class ParishesController { + constructor(private readonly parishesService: ParishesService) {} + + @Get() + @Roles('admin', 'user') + @ApiOperation({ summary: 'Get all parishes' }) + @ApiResponse({ + status: 200, + description: 'Return all parishes', + type: [Parish], + }) + findAll() { + return this.parishesService.findAll(); + } + + @Get(':id') + @Roles('admin', 'user') + @ApiOperation({ summary: 'Get a parish by id' }) + @ApiResponse({ + status: 200, + description: 'Return a parish', + type: Parish, + }) + @ApiResponse({ status: 404, description: 'Parish not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.parishesService.findOne(id); + } + + @Get('municipality/:municipalityId') + @Roles('admin', 'user') + @ApiOperation({ summary: 'Get parishes by municipality id' }) + @ApiResponse({ + status: 200, + description: 'Return parishes by municipality', + type: [Parish], + }) + @ApiResponse({ status: 404, description: 'Municipality not found' }) + findByMunicipality( + @Param('municipalityId', ParseIntPipe) municipalityId: number, + ) { + return this.parishesService.findByMunicipality(municipalityId); + } + + @Post() + @Roles('ADMIN') + @ApiOperation({ summary: 'Create a new parish' }) + @ApiResponse({ + status: 201, + description: 'Parish created', + type: Parish, + }) + create(@Body() createParishDto: CreateParishDto) { + return this.parishesService.create(createParishDto); + } + + @Patch(':id') + @Roles('ADMIN') + @ApiOperation({ summary: 'Update a parish' }) + @ApiResponse({ + status: 200, + description: 'Parish updated', + type: Parish, + }) + @ApiResponse({ status: 404, description: 'Parish not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateParishDto: UpdateParishDto, + ) { + return this.parishesService.update(id, updateParishDto); + } + + @Delete(':id') + @Roles('ADMIN') + @ApiOperation({ summary: 'Delete a parish' }) + @ApiResponse({ status: 200, description: 'Parish deleted' }) + @ApiResponse({ status: 404, description: 'Parish not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.parishesService.remove(id); + } +} diff --git a/apps/api/src/features/configurations/parishes/parishes.module.ts b/apps/api/src/features/configurations/parishes/parishes.module.ts new file mode 100644 index 0000000..d490266 --- /dev/null +++ b/apps/api/src/features/configurations/parishes/parishes.module.ts @@ -0,0 +1,13 @@ +import { DrizzleModule } from '@/database/drizzle.module'; +import { Module } from '@nestjs/common'; +import { MunicipalitiesModule } from '../municipalities/municipalities.module'; +import { ParishesController } from './parishes.controller'; +import { ParishesService } from './parishes.service'; + +@Module({ + imports: [DrizzleModule, MunicipalitiesModule], + controllers: [ParishesController], + providers: [ParishesService], + exports: [ParishesService], +}) +export class ParishesModule {} diff --git a/apps/api/src/features/configurations/parishes/parishes.service.ts b/apps/api/src/features/configurations/parishes/parishes.service.ts new file mode 100644 index 0000000..99b4171 --- /dev/null +++ b/apps/api/src/features/configurations/parishes/parishes.service.ts @@ -0,0 +1,115 @@ +import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; +import * as schema from '@/database/index'; +import { municipalities, parishes } 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 { MunicipalitiesService } from '../municipalities/municipalities.service'; +import { CreateParishDto } from './dto/create-parish.dto'; +import { UpdateParishDto } from './dto/update-parish.dto'; +import { Parish } from './entities/parish.entity'; + +@Injectable() +export class ParishesService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + private municipalitiesService: MunicipalitiesService, + ) {} + + async findAll(): Promise { + return await this.drizzle + .select({ + id: parishes.id, + name: parishes.name, + municipalityId: parishes.municipalityId, + municipalityName: municipalities.name, + created_at: parishes.created_at, + updated_at: parishes.updated_at, + }) + .from(parishes) + .leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id)); + } + + async findOne(id: number): Promise { + const parish = await this.drizzle + .select({ + id: parishes.id, + name: parishes.name, + municipalityId: parishes.municipalityId, + municipalityName: municipalities.name, + created_at: parishes.created_at, + updated_at: parishes.updated_at, + }) + .from(parishes) + .leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id)) + .where(eq(parishes.id, id)); + + if (parish.length === 0) { + throw new HttpException('Parish not found', HttpStatus.NOT_FOUND); + } + + return parish[0]; + } + + async findByMunicipality(municipalityId: number): Promise { + // Verify municipality exists + await this.municipalitiesService.findOne(municipalityId); + + return await this.drizzle + .select({ + id: parishes.id, + name: parishes.name, + municipalityId: parishes.municipalityId, + municipalityName: municipalities.name, + created_at: parishes.created_at, + updated_at: parishes.updated_at, + }) + .from(parishes) + .leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id)) + .where(eq(parishes.municipalityId, municipalityId)); + } + + async create(createParishDto: CreateParishDto): Promise { + // Verify municipality exists + await this.municipalitiesService.findOne(createParishDto.municipalityId); + + const [parish] = await this.drizzle + .insert(parishes) + .values({ + name: createParishDto.name, + municipalityId: createParishDto.municipalityId, + }) + .returning(); + + return this.findOne(parish.id); + } + + async update(id: number, updateParishDto: UpdateParishDto): Promise { + // Check if parish exists + await this.findOne(id); + + // If municipalityId is provided, verify it exists + if (updateParishDto.municipalityId) { + await this.municipalitiesService.findOne(updateParishDto.municipalityId); + } + + await this.drizzle + .update(parishes) + .set({ + name: updateParishDto.name, + municipalityId: updateParishDto.municipalityId, + }) + .where(eq(parishes.id, id)); + + return this.findOne(id); + } + + async remove(id: number): Promise<{ message: string }> { + // Check if parish exists + await this.findOne(id); + + await this.drizzle.delete(parishes).where(eq(parishes.id, id)); + + return { message: 'Parish deleted successfully' }; + } +} diff --git a/apps/api/src/features/configurations/states/dto/create-state.dto.ts b/apps/api/src/features/configurations/states/dto/create-state.dto.ts new file mode 100644 index 0000000..70df1cd --- /dev/null +++ b/apps/api/src/features/configurations/states/dto/create-state.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateStateDto { + @ApiProperty({ + description: 'The name of the state', + example: 'California', + }) + @IsNotEmpty() + @IsString() + name: string; +} \ No newline at end of file diff --git a/apps/api/src/features/configurations/states/dto/update-state.dto.ts b/apps/api/src/features/configurations/states/dto/update-state.dto.ts new file mode 100644 index 0000000..93aaaee --- /dev/null +++ b/apps/api/src/features/configurations/states/dto/update-state.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateStateDto } from './create-state.dto'; + +export class UpdateStateDto extends PartialType(CreateStateDto) {} \ No newline at end of file diff --git a/apps/api/src/features/configurations/states/entities/state.entity.ts b/apps/api/src/features/configurations/states/entities/state.entity.ts new file mode 100644 index 0000000..5edb454 --- /dev/null +++ b/apps/api/src/features/configurations/states/entities/state.entity.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class State { + @ApiProperty({ + description: 'The unique identifier of the state', + example: 1, + }) + id: number; + + @ApiProperty({ + description: 'The name of the state', + example: 'California', + }) + name: string; + + @ApiProperty({ + description: 'The date when the state was created', + example: '2023-01-01T00:00:00.000Z', + }) + created_at?: Date | null; + + @ApiProperty({ + description: 'The date when the state was last updated', + example: '2023-01-01T00:00:00.000Z', + }) + updated_at?: Date | null; +} diff --git a/apps/api/src/features/configurations/states/states.controller.ts b/apps/api/src/features/configurations/states/states.controller.ts new file mode 100644 index 0000000..cda71c6 --- /dev/null +++ b/apps/api/src/features/configurations/states/states.controller.ts @@ -0,0 +1,68 @@ +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 { UpdateStateDto } from './dto//update-state.dto'; +import { CreateStateDto } from './dto/create-state.dto'; +import { State } from './entities/state.entity'; +import { StatesService } from './states.service'; + +@ApiTags('States') +@Controller('configurations/states') +export class StatesController { + constructor(private readonly statesService: StatesService) {} + + @Get() + @Roles('admin', 'user') + @ApiOperation({ summary: 'Get all states' }) + @ApiResponse({ status: 200, description: 'Return all states', type: [State] }) + findAll() { + return this.statesService.findAll(); + } + + @Get(':id') + @Roles('admin', 'user') + @ApiOperation({ summary: 'Get a state by id' }) + @ApiResponse({ status: 200, description: 'Return a state', type: State }) + @ApiResponse({ status: 404, description: 'State not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.statesService.findOne(id); + } + + @Post() + @Roles('admin') + @ApiOperation({ summary: 'Create a new state' }) + @ApiResponse({ status: 201, description: 'State created', type: State }) + create(@Body() createStateDto: CreateStateDto) { + return this.statesService.create(createStateDto); + } + + @Patch(':id') + @Roles('admin') + @ApiOperation({ summary: 'Update a state' }) + @ApiResponse({ status: 200, description: 'State updated', type: State }) + @ApiResponse({ status: 404, description: 'State not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateStateDto: UpdateStateDto, + ) { + return this.statesService.update(id, updateStateDto); + } + + @Delete(':id') + @Roles('admin') + @ApiOperation({ summary: 'Delete a state' }) + @ApiResponse({ status: 200, description: 'State deleted' }) + @ApiResponse({ status: 404, description: 'State not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.statesService.remove(id); + } +} diff --git a/apps/api/src/features/configurations/states/states.module.ts b/apps/api/src/features/configurations/states/states.module.ts new file mode 100644 index 0000000..414bf64 --- /dev/null +++ b/apps/api/src/features/configurations/states/states.module.ts @@ -0,0 +1,12 @@ +import { DrizzleModule } from '@/database/drizzle.module'; +import { Module } from '@nestjs/common'; +import { StatesController } from './states.controller'; +import { StatesService } from './states.service'; + +@Module({ + imports: [DrizzleModule], + controllers: [StatesController], + providers: [StatesService], + exports: [StatesService], +}) +export class StatesModule {} diff --git a/apps/api/src/features/configurations/states/states.service.ts b/apps/api/src/features/configurations/states/states.service.ts new file mode 100644 index 0000000..ad8c883 --- /dev/null +++ b/apps/api/src/features/configurations/states/states.service.ts @@ -0,0 +1,67 @@ +import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; +import * as schema from '@/database/index'; +import { states } 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 { CreateStateDto } from './dto/create-state.dto'; +import { UpdateStateDto } from './dto/update-state.dto'; +import { State } from './entities/state.entity'; + +@Injectable() +export class StatesService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) {} + + async findAll(): Promise { + return await this.drizzle.select().from(states); + } + + async findOne(id: number): Promise { + const state = await this.drizzle + .select() + .from(states) + .where(eq(states.id, id)); + + if (state.length === 0) { + throw new HttpException('State not found', HttpStatus.NOT_FOUND); + } + + return state[0]; + } + + async create(createStateDto: CreateStateDto): Promise { + const [state] = await this.drizzle + .insert(states) + .values({ + name: createStateDto.name, + }) + .returning(); + + return state; + } + + async update(id: number, updateStateDto: UpdateStateDto): Promise { + // Check if state exists + await this.findOne(id); + + await this.drizzle + .update(states) + .set({ + name: updateStateDto.name, + }) + .where(eq(states.id, id)); + + return this.findOne(id); + } + + async remove(id: number): Promise<{ message: string }> { + // Check if state exists + await this.findOne(id); + + await this.drizzle.delete(states).where(eq(states.id, id)); + + return { message: 'State deleted successfully' }; + } +} diff --git a/apps/api/src/features/location/entities/user.entity.ts b/apps/api/src/features/location/entities/user.entity.ts new file mode 100644 index 0000000..049e84c --- /dev/null +++ b/apps/api/src/features/location/entities/user.entity.ts @@ -0,0 +1,16 @@ +export class State { + id: number; + name: string; +} + +export class Municipality { + id: number; + stateId: number; + name: string; +} + +export class Parish { + id: number; + municipalityId: number; + name: string; +} \ No newline at end of file diff --git a/apps/api/src/features/location/location.controller.ts b/apps/api/src/features/location/location.controller.ts new file mode 100644 index 0000000..cd56acf --- /dev/null +++ b/apps/api/src/features/location/location.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { UsersService } from './location.service'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '@/common/decorators'; +// import { Roles } from '../../common/decorators/roles.decorator'; +// import { PaginationDto } from '../../common/dto/pagination.dto'; +@Public() +@ApiTags('location') +@Controller('location') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get("state") + // @Roles('admin') + // @ApiOperation({ summary: 'Get all users with pagination and filters' }) + // @ApiResponse({ status: 200, description: 'Return paginated users.' }) + async findState() { + const data = await this.usersService.StateAll(); + return { message: 'Data fetched successfully', data}; + } + + @Get('municipality/:id') + // @Roles('admin') + // @ApiOperation({ summary: 'Get a user by ID' }) + // @ApiResponse({ status: 200, description: 'Return the user.' }) + // @ApiResponse({ status: 404, description: 'User not found.' }) + async findMunicipality(@Param('id') id: string) { + const data = await this.usersService.MunicioalityAll(id); + return { message: 'Data fetched successfully', data }; + } + + @Get('parish/:id') + // @Roles('admin') + // @ApiOperation({ summary: 'Get a user by ID' }) + // @ApiResponse({ status: 200, description: 'Return the user.' }) + // @ApiResponse({ status: 404, description: 'User not found.' }) + async findParish(@Param('id') id: string) { + const data = await this.usersService.ParishAll(id); + return { message: 'Data fetched successfully', data }; + } +} diff --git a/apps/api/src/features/location/location.module.ts b/apps/api/src/features/location/location.module.ts new file mode 100644 index 0000000..0c18d46 --- /dev/null +++ b/apps/api/src/features/location/location.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './location.controller'; +import { UsersService } from './location.service'; +import { DrizzleModule } from '@/database/drizzle.module'; + +@Module({ + imports: [DrizzleModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class LocationModule {} diff --git a/apps/api/src/features/location/location.service.ts b/apps/api/src/features/location/location.service.ts new file mode 100644 index 0000000..3d3f9ee --- /dev/null +++ b/apps/api/src/features/location/location.service.ts @@ -0,0 +1,44 @@ +import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; +// import { Env, validateString } from '@/common/utils'; +import { Inject, Injectable, HttpException, HttpStatus, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as schema from 'src/database/index'; +import { states, municipalities, parishes } from 'src/database/index'; +import { eq, like, or, SQL, sql, and, not } from 'drizzle-orm'; +import * as bcrypt from 'bcryptjs'; +import { State, Municipality, Parish } from './entities/user.entity'; +// import { PaginationDto } from '../../common/dto/pagination.dto'; + +@Injectable() +export class UsersService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) { } + + async StateAll(): Promise< State[]> { + const find = await this.drizzle + .select() + .from(states) + + return find; + } + + async MunicioalityAll(id: string): Promise< Municipality[]> { + const find = await this.drizzle + .select() + .from(municipalities) + .where(eq(municipalities.stateId, parseInt(id))); + + return find; + } + + async ParishAll(id: string): Promise< Parish[]> { + const find = await this.drizzle + .select() + .from(parishes) + .where(eq(parishes.municipalityId, parseInt(id))); + + return find; + } +} + diff --git a/apps/api/src/features/mail/mail.module.ts b/apps/api/src/features/mail/mail.module.ts new file mode 100644 index 0000000..48c8edc --- /dev/null +++ b/apps/api/src/features/mail/mail.module.ts @@ -0,0 +1,11 @@ +import { MailerModule } from '@nestjs-modules/mailer'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MailService } from './mail.service'; + +@Module({ + imports: [MailerModule, ConfigModule], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/apps/api/src/features/mail/mail.service.spec.ts b/apps/api/src/features/mail/mail.service.spec.ts new file mode 100644 index 0000000..4297913 --- /dev/null +++ b/apps/api/src/features/mail/mail.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MailService } from './mail.service'; + +describe('MailService', () => { + let service: MailService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MailService], + }).compile(); + + service = module.get(MailService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/features/mail/mail.service.ts b/apps/api/src/features/mail/mail.service.ts new file mode 100644 index 0000000..cad291d --- /dev/null +++ b/apps/api/src/features/mail/mail.service.ts @@ -0,0 +1,18 @@ +import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class MailService { + constructor( + private readonly mailerService: MailerService, + private readonly config: ConfigService, + ) {} + + async sendEmail(mailOptions: ISendMailOptions): Promise { + await this.mailerService.sendMail({ + from: `Turbo NPN <${this.config.get('MAIL_USERNAME')}>`, + ...mailOptions, + }); + } +} diff --git a/apps/api/src/features/mail/templates/change-password.mail.ts b/apps/api/src/features/mail/templates/change-password.mail.ts new file mode 100644 index 0000000..7f6c53a --- /dev/null +++ b/apps/api/src/features/mail/templates/change-password.mail.ts @@ -0,0 +1,177 @@ +const ChangePasswordMail = ({ name }: { name: string }) => { + return ` + + + + + + +
+ Join Turbo NPN +
+ + + + + + + +
+ + + + + + +
+ Vercel +
+

+ Turbo NPN +

+

+ Hello, ${name} +

+

+ Turbo NPN send your sign in notification email +

+
+

+ New Sign In Detected +

+
+
+

+ Turbo NPN is a free and open source prodcut +

+
+ + +`; +}; + +export default ChangePasswordMail; diff --git a/apps/api/src/features/mail/templates/confirm-email.mail.ts b/apps/api/src/features/mail/templates/confirm-email.mail.ts new file mode 100644 index 0000000..926e0ba --- /dev/null +++ b/apps/api/src/features/mail/templates/confirm-email.mail.ts @@ -0,0 +1,177 @@ +const SignInMail = ({ name }: { name: string }) => { + return ` + + + + + + +
+ Join Turbo NPN +
+ + + + + + + +
+ + + + + + +
+ Vercel +
+

+ Turbo NPN +

+

+ Hello, ${name} +

+

+ Turbo NPN send your email verification success message +

+
+

+ Success +

+
+
+

+ Turbo NPN is a free and open source prodcut +

+
+ + +`; +}; + +export default SignInMail; diff --git a/apps/api/src/features/mail/templates/forgot-password.mail.ts b/apps/api/src/features/mail/templates/forgot-password.mail.ts new file mode 100644 index 0000000..e3cfe37 --- /dev/null +++ b/apps/api/src/features/mail/templates/forgot-password.mail.ts @@ -0,0 +1,183 @@ +const ForgotPasswordMail = ({ + name, + code, +}: { + name: string; + code: string | number; +}) => { + return ` + + + + + + +
+ Join Turbo NPN +
+ + + + + + + +
+ + + + + + +
+ Vercel +
+

+ Turbo NPN +

+

+ Hello, ${name} +

+

+ Turbo NPN send your password reset code +

+
+

+ ${code} +

+
+
+

+ Turbo NPN is a free and open source prodcut +

+
+ + +`; +}; + +export default ForgotPasswordMail; diff --git a/apps/api/src/features/mail/templates/index.ts b/apps/api/src/features/mail/templates/index.ts new file mode 100644 index 0000000..dcc2384 --- /dev/null +++ b/apps/api/src/features/mail/templates/index.ts @@ -0,0 +1,195 @@ +const EmailTemplate = ({ + name = '', + action = 'sign in', + timestamp = new Date().toLocaleString(), + location = 'Unknown', + device = 'Unknown Device', + ipAddress = 'Unknown IP', +}) => { + return ` + + + + + + + +
+ Security Alert - New ${action} detected for your Turbo NPN account +
+ + + + + + + +
+ + + + + + +
+ Turbo NPN Logo +
+ +

+ Security Alert +

+ +

+ Hello ${name}, +

+ +

+ We detected a new ${action} to your Turbo NPN account. Here are the details: +

+ +
+ + + + + + + + + + + + + + + + + +
Time:${timestamp}
Location:${location}
Device:${device}
IP Address:${ipAddress}
+
+ + + +

+ If you don't recognize this activity, please contact our support team immediately and change your password. +

+ +
+ +
+

+ Turbo NPN - Secure, Fast, Reliable +

+ + + +

+ © 2025 Turbo NPN. All rights reserved. +

+ +

+ Privacy Policy • + Terms of Service • + Unsubscribe +

+
+
+ + +`; +}; + +export default EmailTemplate; diff --git a/apps/api/src/features/mail/templates/register.mail.ts b/apps/api/src/features/mail/templates/register.mail.ts new file mode 100644 index 0000000..71fa827 --- /dev/null +++ b/apps/api/src/features/mail/templates/register.mail.ts @@ -0,0 +1,183 @@ +const RegisterMail = ({ + name, + code, +}: { + name: string; + code: string | number; +}) => { + return ` + + + + + + +
+ Join Turbo NPN +
+ + + + + + + +
+ + + + + + +
+ Vercel +
+

+ Join Turbo NPN +

+

+ Hello, ${name} +

+

+ Turbo NPN send your email verification code +

+
+

+ ${code} +

+
+
+

+ Turbo NPN is a free and open source prodcut +

+
+ + +`; +}; + +export default RegisterMail; diff --git a/apps/api/src/features/mail/templates/sign-in.mail.ts b/apps/api/src/features/mail/templates/sign-in.mail.ts new file mode 100644 index 0000000..33df94a --- /dev/null +++ b/apps/api/src/features/mail/templates/sign-in.mail.ts @@ -0,0 +1,177 @@ +const SignInMail = ({ name }: { name: string }) => { + return ` + + + + + + +
+ Join Turbo NPN +
+ + + + + + + +
+ + + + + + +
+ Vercel +
+

+ Turbo NPN +

+

+ Hello, ${name} +

+

+ Turbo NPN send your sign in notification email +

+
+

+ New Sign In Detected +

+
+
+

+ Turbo NPN is a free and open source prodcut +

+
+ + +`; +}; + +export default SignInMail; diff --git a/apps/api/src/features/roles/dto/create-role.dto.ts b/apps/api/src/features/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..937abf7 --- /dev/null +++ b/apps/api/src/features/roles/dto/create-role.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateRoleDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; +} \ No newline at end of file diff --git a/apps/api/src/features/roles/dto/update-role.dto.ts b/apps/api/src/features/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..3f80ce5 --- /dev/null +++ b/apps/api/src/features/roles/dto/update-role.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRoleDto } from './create-role.dto'; + +export class UpdateRoleDto extends PartialType(CreateRoleDto) {} \ No newline at end of file diff --git a/apps/api/src/features/roles/entities/role.entity.ts b/apps/api/src/features/roles/entities/role.entity.ts new file mode 100644 index 0000000..d39451f --- /dev/null +++ b/apps/api/src/features/roles/entities/role.entity.ts @@ -0,0 +1,6 @@ +export class Role { + id: number; + name: string; + createdAt?: Date; + updatedAt?: Date | null; +} diff --git a/apps/api/src/features/roles/roles.controller.ts b/apps/api/src/features/roles/roles.controller.ts new file mode 100644 index 0000000..4ac24bb --- /dev/null +++ b/apps/api/src/features/roles/roles.controller.ts @@ -0,0 +1,59 @@ +import { Roles } from '@/common/decorators/roles.decorator'; +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { RolesService } from './roles.service'; + +@ApiTags('roles') +@Controller('roles') +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @Roles('admin') + @ApiOperation({ summary: 'Get all roles' }) + @ApiResponse({ status: 200, description: 'Return all roles.' }) + async findAll() { + const data = await this.rolesService.findAll(); + return { message: 'Roles fetched successfully', data }; + } + + @Get(':id') + @Roles('admin') + @ApiOperation({ summary: 'Get a role by ID' }) + @ApiResponse({ status: 200, description: 'Return the role.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + async findOne(@Param('id') id: string) { + const data = await this.rolesService.findOne(+id); + return { message: 'Role fetched successfully', data }; + } + + @Post() + @Roles('admin') + @ApiOperation({ summary: 'Create a new role' }) + @ApiResponse({ status: 201, description: 'Role created successfully.' }) + async create(@Body() createRoleDto: CreateRoleDto) { + const data = await this.rolesService.create(createRoleDto); + return { message: 'Role created successfully', data }; + } + + @Patch(':id') + @Roles('admin') + @ApiOperation({ summary: 'Update a role' }) + @ApiResponse({ status: 200, description: 'Role updated successfully.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + async update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) { + const data = await this.rolesService.update(+id, updateRoleDto); + return { message: 'Role updated successfully', data }; + } + + @Delete(':id') + @Roles('admin') + @ApiOperation({ summary: 'Delete a role' }) + @ApiResponse({ status: 200, description: 'Role deleted successfully.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + async remove(@Param('id') id: string) { + return await this.rolesService.remove(+id); + } +} diff --git a/apps/api/src/features/roles/roles.module.ts b/apps/api/src/features/roles/roles.module.ts new file mode 100644 index 0000000..d726a48 --- /dev/null +++ b/apps/api/src/features/roles/roles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { RolesController } from './roles.controller'; +import { RolesService } from './roles.service'; +import { DrizzleModule } from '@/database/drizzle.module'; + +@Module({ + imports: [DrizzleModule], + controllers: [RolesController], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule {} \ No newline at end of file diff --git a/apps/api/src/features/roles/roles.service.ts b/apps/api/src/features/roles/roles.service.ts new file mode 100644 index 0000000..42735d7 --- /dev/null +++ b/apps/api/src/features/roles/roles.service.ts @@ -0,0 +1,67 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; +import * as schema from 'src/database/index'; +import { roles } from 'src/database/index'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { Role } from './entities/role.entity'; + +@Injectable() +export class RolesService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) {} + + async findAll(): Promise { + return await this.drizzle.select().from(roles); + } + + async findOne(id: number): Promise { + const role = await this.drizzle + .select() + .from(roles) + .where(eq(roles.id, id)); + + if (role.length === 0) { + throw new HttpException('Role not found', HttpStatus.NOT_FOUND); + } + + return role[0]; + } + + async create(createRoleDto: CreateRoleDto): Promise { + const [role] = await this.drizzle + .insert(roles) + .values({ + name: createRoleDto.name, + }) + .returning(); + + return role; + } + + async update(id: number, updateRoleDto: UpdateRoleDto): Promise { + // Check if role exists + await this.findOne(id); + + await this.drizzle + .update(roles) + .set({ + name: updateRoleDto.name, + }) + .where(eq(roles.id, id)); + + return this.findOne(id); + } + + async remove(id: number): Promise<{ message: string }> { + // Check if role exists + await this.findOne(id); + + await this.drizzle.delete(roles).where(eq(roles.id, id)); + + return { message: 'Role deleted successfully' }; + } +} diff --git a/apps/api/src/features/surveys/Untitled-1.json b/apps/api/src/features/surveys/Untitled-1.json new file mode 100644 index 0000000..0e64019 --- /dev/null +++ b/apps/api/src/features/surveys/Untitled-1.json @@ -0,0 +1,76 @@ +[ + { + "id": "q-1", + "type": "simple", + "position": 0, + "question": "Pregunta N° 1 Simple", + "required": true + }, + { + "id": "q-2", + "type": "multiple_choice", + "options": [ + { + "id": "1", + "text": "Opcion Prueba 1" + }, + { + "id": "2", + "text": "Opcion Prueba 2 " + }, + { + "id": "3", + "text": "Opcion Prueba 3" + }, + { + "id": "4", + "text": "Opcion Prueba 4" + } + ], + "position": 1, + "question": "Pregunta de Multiples Opciones N°2 ", + "required": true + }, + { + "id": "q-3", + "type": "single_choice", + "options": [ + { + "id": "1", + "text": "Opcion Unica Prueba 1" + }, + { + "id": "2", + "text": "Opcion Unica Prueba 2" + } + ], + "position": 2, + "question": "Preguntas de una sola opcion N°3 ", + "required": true + }, + { + "id": "q-4", + "type": "select", + "options": [ + { + "id": "1", + "text": "Seleccion 1" + }, + { + "id": "2", + "text": "Seleccion 2 " + }, + { + "id": "3", + "text": "Seleccion 3" + }, + { + "id": "4", + "text": "Seleccion 4" + } + ], + "position": 3, + "question": "Pregunta seleccion N° 4", + "required": true + } +] \ No newline at end of file diff --git a/apps/api/src/features/surveys/dto/create-survey.dto.ts b/apps/api/src/features/surveys/dto/create-survey.dto.ts new file mode 100644 index 0000000..d9380f4 --- /dev/null +++ b/apps/api/src/features/surveys/dto/create-survey.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsDate, IsInt, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class CreateSurveyDto { + @ApiProperty({ description: 'Survey title' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Survey description' }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ description: 'Target audience' }) + @IsString() + @IsNotEmpty() + targetAudience: string; + + @ApiProperty({ description: 'Closing date' }) + @IsOptional() + @IsDate() + closingDate?: Date; + + @ApiProperty({ description: 'Publication status' }) + @IsBoolean() + published: boolean; + + @ApiProperty({ description: 'Survey questions' }) + @IsArray() + @ValidateNested({ each: true }) // Asegura que cada elemento sea validado individualmente + @Type(() => QuestionDto) // Evita que se envuelva en otro array + questions: any[]; +} + + + +class QuestionDto { + @IsString() + id: string; + + @IsInt() + position: number; + + @IsOptional() + @IsString() + question?: string; + + @IsOptional() + @IsString() + content?: string; + + @IsBoolean() + required: boolean; + + @IsString() + type: string; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => OptionsDto) + options?: OptionsDto[]; +} + +class OptionsDto { + @IsString() + id: string; + + @IsString() + text: string; +} \ No newline at end of file diff --git a/apps/api/src/features/surveys/dto/find-for-user.dto.ts b/apps/api/src/features/surveys/dto/find-for-user.dto.ts new file mode 100644 index 0000000..11024d2 --- /dev/null +++ b/apps/api/src/features/surveys/dto/find-for-user.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty } from 'class-validator'; + +export class FindForUserDto { + @ApiProperty({ description: 'Survey rol' }) + @IsArray() + @IsNotEmpty() + rol: any; + +} diff --git a/apps/api/src/features/surveys/dto/response-survey.dto.ts b/apps/api/src/features/surveys/dto/response-survey.dto.ts new file mode 100644 index 0000000..f28a287 --- /dev/null +++ b/apps/api/src/features/surveys/dto/response-survey.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsString } from 'class-validator'; + +export class AnswersSurveyDto { + @ApiProperty({ description: 'Survey id' }) + @IsString() + @IsNotEmpty() + surveyId: string; + + @ApiProperty({ description: 'Survey answers' }) + @IsArray() + @IsNotEmpty() + answers: any; + +} diff --git a/apps/api/src/features/surveys/dto/statistics-response.dto.ts b/apps/api/src/features/surveys/dto/statistics-response.dto.ts new file mode 100644 index 0000000..03e4295 --- /dev/null +++ b/apps/api/src/features/surveys/dto/statistics-response.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class QuestionStatDto { + @ApiProperty({ description: 'Question identifier' }) + questionId: string; + + @ApiProperty({ description: 'Question label' }) + label: string; + + @ApiProperty({ description: 'Count of responses for this option' }) + count: number; +} + +export class SurveyDetailDto { + @ApiProperty({ description: 'Survey ID' }) + id: number; + + @ApiProperty({ description: 'Survey title' }) + title: string; + + @ApiProperty({ description: 'Survey description' }) + description: string; + + @ApiProperty({ description: 'Total responses received' }) + totalResponses: number; + + @ApiProperty({ description: 'Target audience' }) + targetAudience: any; + + @ApiProperty({ description: 'Creation date' }) + createdAt: string; + + @ApiProperty({ description: 'Closing date' }) + closingDate?: string | null; + + @ApiProperty({ description: 'Question statistics', type: [QuestionStatDto] }) + // @ApiProperty({ description: 'Question statistics' }) + questionStats: QuestionStatDto[]; + // questionStats: any; +} + +export class SurveyStatisticsResponseDto { + @ApiProperty({ description: 'Total number of surveys' }) + totalSurveys: number; + + @ApiProperty({ description: 'Total number of responses across all surveys' }) + totalResponses: number; + + @ApiProperty({ description: 'Completion rate percentage' }) + completionRate: number; + + @ApiProperty({ description: 'Surveys created by month' }) + surveysByMonth: { month: string; count: number }[]; + + @ApiProperty({ description: 'Responses by audience type' }) + responsesByAudience: { name: any; value: number }[]; + + @ApiProperty({ description: 'Response distribution by survey' }) + responseDistribution: { title: string; responses: number }[]; + + @ApiProperty({ description: 'Detailed statistics for each survey', type: [SurveyDetailDto] }) + surveyDetails: SurveyDetailDto[]; +} \ No newline at end of file diff --git a/apps/api/src/features/surveys/dto/update-survey.dto.ts b/apps/api/src/features/surveys/dto/update-survey.dto.ts new file mode 100644 index 0000000..50d3283 --- /dev/null +++ b/apps/api/src/features/surveys/dto/update-survey.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateSurveyDto } from './create-survey.dto'; + + +export class UpdateSurveyDto extends PartialType(CreateSurveyDto) {} \ No newline at end of file diff --git a/apps/api/src/features/surveys/entities/survey.entity.ts b/apps/api/src/features/surveys/entities/survey.entity.ts new file mode 100644 index 0000000..f329602 --- /dev/null +++ b/apps/api/src/features/surveys/entities/survey.entity.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class Survey { + @ApiProperty({ description: 'Survey ID' }) + id: number; + + @ApiProperty({ description: 'Survey title' }) + title: string; + + @ApiProperty({ description: 'Survey description' }) + description: string; + + @ApiProperty({ description: 'Target audience for the survey' }) + targetAudience: string; + + @ApiProperty({ description: 'Survey closing date' }) + closingDate?: Date; + + @ApiProperty({ description: 'Survey publication status' }) + published: boolean; + + @ApiProperty({ description: 'Survey questions' }) + questions: any[]; + + @ApiProperty({ description: 'Creation date' }) + created_at?: Date; + + @ApiProperty({ description: 'Last update date' }) + updated_at?: Date; +} \ No newline at end of file diff --git a/apps/api/src/features/surveys/surveys.controller.ts b/apps/api/src/features/surveys/surveys.controller.ts new file mode 100644 index 0000000..797295d --- /dev/null +++ b/apps/api/src/features/surveys/surveys.controller.ts @@ -0,0 +1,125 @@ +import { Roles } from '@/common/decorators/roles.decorator'; +import { PaginationDto } from '@/common/dto/pagination.dto'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CreateSurveyDto } from './dto/create-survey.dto'; +import { UpdateSurveyDto } from './dto/update-survey.dto'; +import { SurveysService } from './surveys.service'; +import { AnswersSurveyDto } from './dto/response-survey.dto'; +import { Request } from 'express'; +import { FindForUserDto } from './dto/find-for-user.dto'; +import { SurveyStatisticsResponseDto } from './dto/statistics-response.dto'; + +@ApiTags('surveys') +@Controller('surveys') +export class SurveysController { + constructor(private readonly surveysService: SurveysService) {} + + @Get() + @Roles('admin') + @ApiOperation({ summary: 'Get all surveys with pagination and filters' }) + @ApiResponse({ status: 200, description: 'Return paginated surveys.' }) + async findAll(@Query() paginationDto: PaginationDto) { + const result = await this.surveysService.findAll(paginationDto); + return { + message: 'Surveys fetched successfully', + data: result.data, + meta: result.meta, + }; + } + + + + @Post('for-user') + @ApiOperation({ summary: 'Get all surveys with pagination and filters for user' }) + @ApiResponse({ status: 200, description: 'Return paginated surveys for user.' }) + async findAllForUser(@Req() req: Request, @Query() paginationDto: PaginationDto, @Body() findForUserDto: FindForUserDto) { + + const userId = req['user'].id; + + const result = await this.surveysService.findAllForUser(paginationDto, userId, findForUserDto); + + return { + message: 'Surveys fetched successfully', + data: result.data, + meta: result.meta, + }; + } + + @Get('statistics') + @Roles('admin', 'superadmin', 'autoridad') + @ApiOperation({ summary: 'Get survey statistics' }) + + @ApiResponse({ status: 200, description: 'Return survey statistics.'}) + + async getStatistics() { + const data = await this.surveysService.getStatistics(); + return { + message: 'Survey statistics fetched successfully', + data + }; + } + + @Get(':id') + @ApiOperation({ summary: 'Get a survey by ID' }) + @ApiResponse({ status: 200, description: 'Return the survey.' }) + @ApiResponse({ status: 404, description: 'Survey not found.' }) + async findOne(@Param('id') id: string) { + const data = await this.surveysService.findOne(id); + return { message: 'Survey fetched successfully', data }; + } + + @Post() + @Roles('admin') + @ApiOperation({ summary: 'Create a new survey' }) + @ApiResponse({ status: 201, description: 'Survey created successfully.' }) + async create(@Body() createSurveyDto: CreateSurveyDto) { + console.log(createSurveyDto); + + const data = await this.surveysService.create(createSurveyDto); + return { message: 'Survey created successfully', data }; + } + + @Patch(':id') + @Roles('admin') + @ApiOperation({ summary: 'Update a survey' }) + @ApiResponse({ status: 200, description: 'Survey updated successfully.' }) + @ApiResponse({ status: 404, description: 'Survey not found.' }) + async update( + @Param('id') id: string, + @Body() updateSurveyDto: UpdateSurveyDto, + ) { + const data = await this.surveysService.update(id, updateSurveyDto); + return { message: 'Survey updated successfully', data }; + } + + @Delete(':id') + @Roles('admin') + @ApiOperation({ summary: 'Delete a survey' }) + @ApiResponse({ status: 200, description: 'Survey deleted successfully.' }) + @ApiResponse({ status: 404, description: 'Survey not found.' }) + async remove(@Param('id') id: string) { + return await this.surveysService.remove(id); + } + + @Post('answers') + @ApiOperation({ summary: 'Create a new answers' }) + @ApiResponse({ status: 201, description: 'Survey answers successfully.' }) + async answers(@Req() req: Request, @Body() answersSurveyDto: AnswersSurveyDto) { + const userId = (req as any).user?.id; + const data = await this.surveysService.answers(Number(userId),answersSurveyDto); + return { message: 'Survey answers created successfully', data }; + } + + +} \ No newline at end of file diff --git a/apps/api/src/features/surveys/surveys.module.ts b/apps/api/src/features/surveys/surveys.module.ts new file mode 100644 index 0000000..eef0e23 --- /dev/null +++ b/apps/api/src/features/surveys/surveys.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SurveysService } from './surveys.service'; +import { SurveysController } from './surveys.controller'; + +@Module({ + controllers: [SurveysController], + providers: [SurveysService], + exports: [SurveysService], +}) +export class SurveysModule {} \ No newline at end of file diff --git a/apps/api/src/features/surveys/surveys.service.ts b/apps/api/src/features/surveys/surveys.service.ts new file mode 100644 index 0000000..7a7e6a8 --- /dev/null +++ b/apps/api/src/features/surveys/surveys.service.ts @@ -0,0 +1,535 @@ +import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; +import { surveys, answersSurveys, viewSurveys } from '@/database/index'; +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as schema from '@/database/index'; +import { CreateSurveyDto } from './dto/create-survey.dto'; +import { UpdateSurveyDto } from './dto/update-survey.dto'; +import { and, count, eq, ilike, isNull, or, sql } from 'drizzle-orm'; +import { SurveyDetailDto, SurveyStatisticsResponseDto } from './dto/statistics-response.dto'; +import { PaginationDto } from '@/common/dto/pagination.dto'; +import { AnswersSurveyDto } from './dto/response-survey.dto'; +import { FindForUserDto } from './dto/find-for-user.dto'; + +@Injectable() +export class SurveysService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) { } + + async findAll(paginationDto: PaginationDto) { + const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {}; + const offset = (page - 1) * limit; + + // Build search condition + const searchCondition = ilike(surveys.title, `%${search}%`); + + + // Build sort condition + const orderBy = sortOrder === 'asc' + ? sql`${surveys[sortBy as keyof typeof surveys]} asc` + : sql`${surveys[sortBy as keyof typeof surveys]} desc`; + + // Get total count for pagination + const totalCountResult = await this.drizzle + .select({ count: sql`count(*)` }) + .from(surveys) + .where(searchCondition); + + const totalCount = Number(totalCountResult[0].count); + const totalPages = Math.ceil(totalCount / limit); + + + // Get paginated data + const data = await this.drizzle + .select() + .from(surveys) + .where(searchCondition) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + const dataSurvey = data.map((survey) => { + return { + ...survey, + closingDate: survey.closingDate ? new Date(survey.closingDate) : null, + }; + }); + + // Build pagination metadata + const meta = { + page, + limit, + totalCount, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + nextPage: page < totalPages ? page + 1 : null, + previousPage: page > 1 ? page - 1 : null, + }; + + return { data: dataSurvey, meta }; + + } + + async findAllForUser(paginationDto: PaginationDto, userId: number, findForUserDto: FindForUserDto) { + const { page = 1, limit = 10, search = '', sortBy = 'created_at', sortOrder = 'asc' } = paginationDto || {}; + const offset = (page - 1) * limit; + + let searchCondition : any = false + + // Build search condition + // if (findForUserDto.rol[0].rol === 'superadmin' || findForUserDto.rol[0].rol == 'admin') { + // searchCondition = and( + // or(eq(viewSurveys.targetAudience, 'producers'), eq(viewSurveys.targetAudience, 'organization'), eq(viewSurveys.targetAudience, 'all')), + // or(eq(viewSurveys.user_id, userId), isNull(viewSurveys.user_id)) + // ); + // } else { + // searchCondition = and( + // or(eq(viewSurveys.targetAudience, findForUserDto.rol[0].rol), eq(viewSurveys.targetAudience, 'all')), + // or(eq(viewSurveys.user_id, userId), isNull(viewSurveys.user_id)) + // ); + // } + + if (findForUserDto.rol[0].rol !== 'superadmin' && findForUserDto.rol[0].rol !== 'admin') { + searchCondition = or(eq(surveys.targetAudience, findForUserDto.rol[0].rol), eq(surveys.targetAudience, 'all')) + } + + // console.log(searchCondition); + + // Build sort condition + const orderBy = sortOrder === 'asc' + ? sql`${surveys[sortBy as keyof typeof surveys]} asc` + : sql`${surveys[sortBy as keyof typeof surveys]} desc`; + + // Get total count for pagination + const totalCountResult = await this.drizzle + .select({ count: sql`count(*)` }) + .from(surveys) + .leftJoin(answersSurveys, and(eq(answersSurveys.surveyId,surveys.id),eq(answersSurveys.userId,userId))) + .where(searchCondition); + + const totalCount = Number(totalCountResult[0].count); + const totalPages = Math.ceil(totalCount / limit); + + // Get paginated data + const data = await this.drizzle + .select() + .from(surveys) + .leftJoin(answersSurveys, and(eq(answersSurveys.surveyId,surveys.id),eq(answersSurveys.userId,userId))) + .where(searchCondition) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + // Build pagination metadata + const meta = { + page, + limit, + totalCount, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + nextPage: page < totalPages ? page + 1 : null, + previousPage: page > 1 ? page - 1 : null, + }; + + return { data, meta }; + } + + async findOne(id: string) { + const survey = await this.drizzle + .select() + .from(surveys) + .where(eq(surveys.id, parseInt(id))); + + if (survey.length === 0) { + throw new HttpException('Survey not found', HttpStatus.NOT_FOUND); + } + + const dataSurvey = survey.map((survey) => { + return { + ...survey, + closingDate: survey.closingDate ? new Date(survey.closingDate) : null, + }; + }); + + return dataSurvey[0]; + } + + async findByTitle(title: string) { + return await this.drizzle + .select() + .from(surveys) + .where(eq(surveys.title, title)); + } + + async create(createSurveyDto: CreateSurveyDto) { + + const find = await this.findByTitle(createSurveyDto.title); + + if (find.length !== 0) { + throw new HttpException('Survey already exists', HttpStatus.BAD_REQUEST); + } + + const survey = await this.drizzle + .insert(surveys) + .values({ + ...createSurveyDto, + closingDate: createSurveyDto.closingDate?.toISOString(), + }) + .returning(); + const dataSurvey = survey.map((survey) => { + return { + ...survey, + closingDate: survey.closingDate ? new Date(survey.closingDate) : null, + }; + }); + + return dataSurvey[0]; + } + + async update(id: string, updateSurveyDto: UpdateSurveyDto) { + + const find = await this.findOne(id) + if (!find) { + throw new HttpException('Survey not found', HttpStatus.BAD_REQUEST) + } + + const find2 = await this.findByTitle(updateSurveyDto.title ?? ''); + + if (find2.length !== 0 && find2[0].id !== parseInt(id)) { + throw new HttpException('Survey already exists', HttpStatus.BAD_REQUEST); + } + + const survey = await this.drizzle + .update(surveys) + .set({ + ...updateSurveyDto, + closingDate: updateSurveyDto.closingDate?.toISOString(), + updated_at: new Date(), + }) + .where(eq(surveys.id, parseInt(id))) + .returning(); + + const dataSurvey = survey.map((survey) => { + return { + ...survey, + closingDate: survey.closingDate ? new Date(survey.closingDate) : null, + }; + }); + + return dataSurvey[0]; + } + + async remove(id: string) { + const find = await this.findOne(id); + if (!find) { + throw new HttpException('Survey not found', HttpStatus.BAD_REQUEST); + } + + await this.drizzle + .delete(surveys) + .where(eq(surveys.id, parseInt(id))); + + return { message: 'Survey deleted successfully' }; + } + + async answers(userId: number, answersSurveyDto: AnswersSurveyDto) { + + const find = await this.drizzle.select() + .from(answersSurveys) + .where(and(eq(answersSurveys.surveyId, Number(answersSurveyDto.surveyId)), (eq(answersSurveys.userId, userId)))); + + + if (find.length !== 0) { + throw new HttpException('Survey answers already exists', HttpStatus.BAD_REQUEST); + } + + const survey = await this.drizzle + .insert(answersSurveys) + .values({ + ...answersSurveyDto, + surveyId: Number(answersSurveyDto.surveyId), + userId: userId, + }) + .returning(); + + return survey[0]; + } + + async getStatistics(): Promise { + // Obtener el número total de encuestas + const totalSurveys = await this.getTotalSurveysCount(); + // Obtener el número total de respuestas + const totalResponses = await this.getTotalResponsesCount(); + + // Calcular la tasa de finalización + const completionRate = totalSurveys > 0 ? Math.round((totalResponses / totalSurveys) * 100) : 0; + + // Obtener las encuestas por mes + const surveysByMonth = await this.getSurveysByMonth(); + + // Obtener las respuestas por audiencia + const responsesByAudience = await this.getResponsesByAudience(); + + // Obtener la distribución de respuestas por encuesta + const responseDistribution = await this.getResponseDistribution(); + + // Obtener las estadísticas detalladas de las encuestas + const surveyDetails = await this.getSurveyDetails(); + + return { + totalSurveys, + totalResponses, + completionRate, + surveysByMonth, + responsesByAudience, + responseDistribution, + surveyDetails, + } + } + + private async getTotalSurveysCount(): Promise { + const result = await this.drizzle + .select({ count: sql`count(*)` }) + .from(surveys); + return Number(result[0].count); + } + + private async getTotalResponsesCount(): Promise { + const result = await this.drizzle + .select({ count: sql`count(*)` }) + .from(answersSurveys); + return Number(result[0].count); + } + + private async getSurveysByMonth(): Promise<{ month: string; count: number }[]> { + const result = await this.drizzle + .select({ + month: sql`to_char(created_at, 'YYYY-MM')`, + count: sql`count(*)`, + }) + .from(surveys) + .groupBy(sql`to_char(created_at, 'YYYY-MM')`) + .orderBy(sql`to_char(created_at, 'YYYY-MM')`); + return result.map(item => ({ month: item.month, count: Number(item.count) })); + } + + private async getResponsesByAudience(): Promise<{ name: any; value: number }[]> { + const result = await this.drizzle + .select({ + audience: surveys.targetAudience, + count: sql`count(*)`, + }) + .from(answersSurveys) + .leftJoin(surveys, eq(answersSurveys.surveyId, surveys.id)) + .groupBy(surveys.targetAudience); + return result.map(item => + { + let audience = 'Sin definir' + if (item.audience == 'all') { + audience = 'General' + } else if (item.audience == 'organization') { + audience = 'Organización' + } else if (item.audience == 'producers') { + audience = 'Productores' + } + return ({ name: audience, value: Number(item.count) }) + } + ); + } + + private async getResponseDistribution(): Promise<{ title: string; responses: number }[]> { + const result = await this.drizzle + .select({ + id: surveys.id, + title: surveys.title, + responses: sql`count(${answersSurveys.id})`, + }) + .from(surveys) + .leftJoin(answersSurveys, eq(surveys.id, answersSurveys.surveyId)) + .groupBy(surveys.id, surveys.title) + .orderBy(sql`count(${answersSurveys.id}) desc`) + .limit(10); + return result.map(item => ({ title: item.title, responses: Number(item.responses) })); + } + + private async getSurveyDetails(): Promise { + const allSurveys = await this.drizzle + .select({ + id: surveys.id, + title: surveys.title, + description: surveys.description, + targetAudience: surveys.targetAudience, + createdAt: surveys.created_at, + closingDate: surveys.closingDate, + questions: surveys.questions, + }) + .from(surveys); + + + return await Promise.all( + allSurveys.map(async (survey) => { + // Obtener el número total de respuestas para esta encuesta + const totalSurveyResponses = await this.getTotalSurveyResponses(survey.id); + + // Obtener todas las respuestas para esta encuesta + const answersResult = await this.drizzle + .select({ answers: answersSurveys.answers }) + .from(answersSurveys) + .where(eq(answersSurveys.surveyId, survey.id)); + + let audience = 'Sin definir' + if (survey.targetAudience == 'all') { + audience = 'General' + } else if (survey.targetAudience == 'organization') { + audience = 'Organización' + } else if (survey.targetAudience == 'producers') { + audience = 'Productores' + } + + // Procesar las estadísticas de las preguntas + const questionStats = this.processQuestionStats(survey.questions as any[], answersResult); + + return { + id: survey.id, + title: survey.title, + description: survey.description, + totalResponses: totalSurveyResponses, + // targetAudience: survey.targetAudience, + targetAudience: audience, + createdAt: survey.createdAt.toISOString(), + closingDate: survey.closingDate ? new Date(survey.closingDate).toISOString() : undefined, + questionStats, + }; + }) + ); + } + + private async getTotalSurveyResponses(surveyId: number): Promise { + const result = await this.drizzle + .select({ count: sql`count(*)` }) + .from(answersSurveys) + .where(eq(answersSurveys.surveyId, surveyId)); + return Number(result[0].count); + } + + // ================================== + + private processQuestionStats(questions: any[], answersResult: { answers: any }[]) { + // Initialize counters for each question option + const questionStats: Array<{ questionId: string; label: string; count: number }> = []; + + // Skip title questions (type: 'title') + const surveyQuestions = questions.filter(q => q.type !== 'title'); + + // console.log(surveyQuestions); + // console.log('Se llamo a processQuestionStats()'); + + for (const question of surveyQuestions) { + // console.log('Bucle1 se ejecuto'); + + // For single choice, multiple choice, and select questions + // if (['single_choice', 'multiple_choice', 'select'].includes(question.type)) { + const optionCounts: Record = {}; + + // // Initialize counts for each option + // for (const option of question.options) { + // optionCounts[option.text] = 0; + // } + + // // Count responses for each option + // for (const answerObj of answersResult) { + // const answer = answerObj.answers.find(a => a.questionId === question.id); + + // if (answer) { + // if (Array.isArray(answer.value)) { + // // For multiple choice questions + // for (const value of answer.value) { + // if (optionCounts[value] !== undefined) { + // optionCounts[value]++; + // } + // } + // } else { + // // For single choice questions + // if (optionCounts[answer.value] !== undefined) { + // optionCounts[answer.value]++; + // } + // } + // } + // } + + // // Convert to the required format + // for (const option of question.options) { + // questionStats.push({ + // questionId: String(question.id), + // label: option.text, + // count: optionCounts[option.value] || 0, + // }); + // } + + if (question.type == 'multiple_choice') { + for (const option of question.options) { + console.log(option); + let count :number = 0 + // Count responses for each option + for (const obj of answersResult) { + console.log(obj.answers) + const resp = obj.answers.find(a => a.questionId == question.id) + const respArray = resp.value.split(",") + // console.log(); + + if (respArray[option.id] == 'true') { + count++ + } + } + optionCounts[option.text] = count + // Convert to the required format + questionStats.push({ + questionId: String(question.id), + label: `${question.question} ${option.text}`, + count: optionCounts[option.text] || 0, + }) + } + + } else if (question.type == 'single_choice' || question.type == 'select') { + for (const option of question.options) { + let count :number = 0 + // Count responses for each option + for (const obj of answersResult) { + const resp = obj.answers.find(a => a.questionId == question.id) + if (resp.value == option.id) { + count++ + } + } + optionCounts[option.text] = count + // Convert to the required format + questionStats.push({ + questionId: String(question.id), + label: `${question.question} ${option.text}`, + count: optionCounts[option.text] || 0, + }) + } + } else if (question.type === 'simple') { + // For simple text questions, just count how many responses + let responseCount = 0; + + for (const answerObj of answersResult) { + const answer = answerObj.answers.find(a => a.questionId === question.id); + if (answer && answer.value) { + responseCount++; + } + } + + questionStats.push({ + questionId: String(question.id), + label: question.question, + count: responseCount, + }); + } + } + + return questionStats; + } +} \ No newline at end of file diff --git a/apps/api/src/features/user-roles/dto/assign-role.dto.ts b/apps/api/src/features/user-roles/dto/assign-role.dto.ts new file mode 100644 index 0000000..d799e25 --- /dev/null +++ b/apps/api/src/features/user-roles/dto/assign-role.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; + +export class AssignRoleDto { + @ApiProperty() + @IsNumber() + userId: number; + + @ApiProperty() + @IsNumber() + roleId: number; +} \ No newline at end of file diff --git a/apps/api/src/features/user-roles/user-roles.controller.ts b/apps/api/src/features/user-roles/user-roles.controller.ts new file mode 100644 index 0000000..daa33bb --- /dev/null +++ b/apps/api/src/features/user-roles/user-roles.controller.ts @@ -0,0 +1,46 @@ +import { Roles } from '@/common/decorators/roles.decorator'; +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AssignRoleDto } from './dto/assign-role.dto'; +import { UserRolesService } from './user-roles.service'; + +@ApiTags('user-roles') +@Controller('user-roles') +export class UserRolesController { + constructor(private readonly userRolesService: UserRolesService) {} + + @Get('user/:userId') + @Roles('admin') + @ApiOperation({ summary: 'Get roles by user ID' }) + @ApiResponse({ status: 200, description: 'Return roles for the user.' }) + @ApiResponse({ status: 404, description: 'User not found.' }) + async getRolesByUserId(@Param('userId') userId: string) { + const data = await this.userRolesService.getRolesByUserId(+userId); + return { message: 'Roles fetched successfully', data }; + } + + @Post('assign') + @Roles('admin') + @ApiOperation({ summary: 'Assign a role to a user' }) + @ApiResponse({ status: 200, description: 'Role assigned successfully.' }) + @ApiResponse({ status: 404, description: 'User or role not found.' }) + async assignRoleToUser(@Body() assignRoleDto: AssignRoleDto) { + const data = await this.userRolesService.assignRoleToUser(assignRoleDto); + return { message: 'Role assigned successfully', data }; + } + + @Delete('user/:userId/role/:roleId') + @Roles('admin') + @ApiOperation({ summary: 'Remove a role from a user' }) + @ApiResponse({ status: 200, description: 'Role removed successfully.' }) + @ApiResponse({ + status: 404, + description: 'User-role relationship not found.', + }) + async removeRoleFromUser( + @Param('userId') userId: string, + @Param('roleId') roleId: string, + ) { + return await this.userRolesService.removeRoleFromUser(+userId, +roleId); + } +} diff --git a/apps/api/src/features/user-roles/user-roles.module.ts b/apps/api/src/features/user-roles/user-roles.module.ts new file mode 100644 index 0000000..76dd9ae --- /dev/null +++ b/apps/api/src/features/user-roles/user-roles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UserRolesController } from './user-roles.controller'; +import { UserRolesService } from './user-roles.service'; +import { DrizzleModule } from '@/database/drizzle.module'; + +@Module({ + imports: [DrizzleModule], + controllers: [UserRolesController], + providers: [UserRolesService], + exports: [UserRolesService], +}) +export class UserRolesModule {} \ No newline at end of file diff --git a/apps/api/src/features/user-roles/user-roles.service.ts b/apps/api/src/features/user-roles/user-roles.service.ts new file mode 100644 index 0000000..d636d37 --- /dev/null +++ b/apps/api/src/features/user-roles/user-roles.service.ts @@ -0,0 +1,118 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; +import * as schema from 'src/database/index'; +import { users, roles, usersRole } from 'src/database/index'; +import { AssignRoleDto } from './dto/assign-role.dto'; + +@Injectable() +export class UserRolesService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) {} + + async getRolesByUserId(userId: number) { + // Check if user exists + const user = await this.drizzle + .select() + .from(users) + .where(eq(users.id, userId)); + + if (user.length === 0) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + // Get roles for the user + const userRoles = await this.drizzle + .select({ + id: roles.id, + name: roles.name, + }) + .from(usersRole) + .leftJoin(roles, eq(roles.id, usersRole.roleId)) + .where(eq(usersRole.userId, userId)); + + return userRoles; + } + + async assignRoleToUser(assignRoleDto: AssignRoleDto) { + const { userId, roleId } = assignRoleDto; + + // Check if user exists + const user = await this.drizzle + .select() + .from(users) + .where(eq(users.id, userId)); + + if (user.length === 0) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + // Check if role exists + const role = await this.drizzle + .select() + .from(roles) + .where(eq(roles.id, roleId)); + + if (role.length === 0) { + throw new HttpException('Role not found', HttpStatus.NOT_FOUND); + } + + // Check if the user already has this role + const existingUserRole = await this.drizzle + .select() + .from(usersRole) + .where( + and( + eq(usersRole.userId, userId), + eq(usersRole.roleId, roleId), + ), + ); + + if (existingUserRole.length > 0) { + throw new HttpException('User already has this role', HttpStatus.BAD_REQUEST); + } + + // Assign role to user + await this.drizzle.insert(usersRole).values({ + userId, + roleId, + }); + + // Return the updated roles + return this.getRolesByUserId(userId); + } + + async removeRoleFromUser(userId: number, roleId: number) { + // Check if the user-role relationship exists + const userRole = await this.drizzle + .select() + .from(usersRole) + .where( + and( + eq(usersRole.userId, userId), + eq(usersRole.roleId, roleId), + ), + ); + + if (userRole.length === 0) { + throw new HttpException( + 'User-role relationship not found', + HttpStatus.NOT_FOUND, + ); + } + + // Remove the role from the user + await this.drizzle + .delete(usersRole) + .where( + and( + eq(usersRole.userId, userId), + eq(usersRole.roleId, roleId), + ), + ); + + return { message: 'Role removed from user successfully' }; + } +} \ No newline at end of file diff --git a/apps/api/src/features/users/dto/create-user.dto.ts b/apps/api/src/features/users/dto/create-user.dto.ts new file mode 100644 index 0000000..882bf1d --- /dev/null +++ b/apps/api/src/features/users/dto/create-user.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateUserDto { + @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() + role: number; +} diff --git a/apps/api/src/features/users/dto/update-user.dto.ts b/apps/api/src/features/users/dto/update-user.dto.ts new file mode 100644 index 0000000..933a7d9 --- /dev/null +++ b/apps/api/src/features/users/dto/update-user.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { CreateUserDto } from './create-user.dto'; + +// import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateUserDto extends PartialType(CreateUserDto) { + // export class UpdateUserDto { + @IsOptional() + username: string; + + @IsOptional() + email: string; + + @IsOptional() + fullname: string; + + @IsOptional() + phone: string; + + @IsOptional() + password: string; + + @ApiProperty() + @IsString() + @IsOptional() + isActive: string; + + @IsOptional() + state: string | number | null; + + @IsOptional() + municipality: string | number | null; + + @IsOptional() + parish: string | number | null; + + @IsOptional() + role: number; +} diff --git a/apps/api/src/features/users/entities/user.entity.ts b/apps/api/src/features/users/entities/user.entity.ts new file mode 100644 index 0000000..2270748 --- /dev/null +++ b/apps/api/src/features/users/entities/user.entity.ts @@ -0,0 +1,25 @@ +export class User { + id?: number; + username!: string; + email!: string; + fullname!: string; + phone?: string | null; + password?: string; + isTwoFactorEnabled?: boolean; + twoFactorSecret?: string | null; + isEmailVerified?: boolean; + isActive!: boolean; + created_at?: Date | null; + updated_at?: Date | null; + state?: string | number | null; + municipality?: string | number | null; + parish?: string | number | null; +} + +export class UserRole { + id!: number; + userId!: number; + roleId!: number; + created_at?: Date | null; + updated_at?: Date | null; +} \ No newline at end of file diff --git a/apps/api/src/features/users/users.controller.ts b/apps/api/src/features/users/users.controller.ts new file mode 100644 index 0000000..80d26fc --- /dev/null +++ b/apps/api/src/features/users/users.controller.ts @@ -0,0 +1,81 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +@ApiTags('users') +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + @Roles('admin') + @ApiOperation({ summary: 'Get all users with pagination and filters' }) + @ApiResponse({ status: 200, description: 'Return paginated users.' }) + async findAll(@Query() paginationDto: PaginationDto) { + const result = await this.usersService.findAll(paginationDto); + return { + message: 'Users fetched successfully', + data: result.data, + meta: result.meta + }; + } + + @Get(':id') + // @Roles('admin') + @ApiOperation({ summary: 'Get a user by ID' }) + @ApiResponse({ status: 200, description: 'Return the user.' }) + @ApiResponse({ status: 404, description: 'User not found.' }) + async findOne(@Param('id') id: string) { + const data = await this.usersService.findOne(id); + return { message: 'User fetched successfully', data }; + } + + @Post() + @Roles('admin') + @ApiOperation({ summary: 'Create a new user' }) + @ApiResponse({ status: 201, description: 'User created successfully.' }) + async create( + @Body() createUserDto: CreateUserDto, + @Query('roleId') roleId?: string, + ) { + const data = await this.usersService.create( + createUserDto, + roleId ? parseInt(roleId) : undefined, + ); + return { message: 'User created successfully', data }; + } + + @Patch(':id') + @Roles('admin') + @ApiOperation({ summary: 'Update a user' }) + @ApiResponse({ status: 200, description: 'User updated successfully.' }) + @ApiResponse({ status: 404, description: 'User not found.' }) + async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + const data = await this.usersService.update(id, updateUserDto); + return { message: 'User updated successfully', data }; + } + + @Patch('profile/:id') + // @Roles('admin') + @ApiOperation({ summary: 'Update a user' }) + @ApiResponse({ status: 200, description: 'User updated successfully.' }) + @ApiResponse({ status: 400, description: 'email already exists.' }) + @ApiResponse({ status: 404, description: 'User not found.' }) + async updateProfile(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + const data = await this.usersService.updateProfile(id, updateUserDto); + return { message: 'User updated successfully', data }; + } + + @Delete(':id') + @Roles('admin') + @ApiOperation({ summary: 'Delete a user' }) + @ApiResponse({ status: 200, description: 'User deleted successfully.' }) + @ApiResponse({ status: 404, description: 'User not found.' }) + async remove(@Param('id') id: string) { + return await this.usersService.remove(id); + } +} diff --git a/apps/api/src/features/users/users.module.ts b/apps/api/src/features/users/users.module.ts new file mode 100644 index 0000000..ee7a016 --- /dev/null +++ b/apps/api/src/features/users/users.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { DrizzleModule } from '@/database/drizzle.module'; + +@Module({ + imports: [DrizzleModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/features/users/users.service.ts b/apps/api/src/features/users/users.service.ts new file mode 100644 index 0000000..ec6a114 --- /dev/null +++ b/apps/api/src/features/users/users.service.ts @@ -0,0 +1,288 @@ +import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; +import { Env, validateString } from '@/common/utils'; +import { Inject, Injectable, HttpException, HttpStatus, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as schema from 'src/database/index'; +import { users, roles, usersRole } from 'src/database/index'; +import { eq, like, or, SQL, sql, and, not } from 'drizzle-orm'; +import * as bcrypt from 'bcryptjs'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { User } from './entities/user.entity'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +@Injectable() +export class UsersService { + constructor( + @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, + ) { } + + async findAll(paginationDto?: PaginationDto): Promise<{ data: User[], meta: any }> { + const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {}; + + // Calculate offset + const offset = (page - 1) * limit; + + // Build search condition + let searchCondition: SQL | undefined; + if (search) { + searchCondition = or( + like(users.username, `%${search}%`), + like(users.email, `%${search}%`), + like(users.fullname, `%${search}%`) + ); + } + + // Build sort condition + const orderBy = sortOrder === 'asc' + ? sql`${users[sortBy as keyof typeof users]} asc` + : sql`${users[sortBy as keyof typeof users]} desc`; + + // Get total count for pagination + const totalCountResult = await this.drizzle + .select({ count: sql`count(*)` }) + .from(users) + .where(searchCondition); + + const totalCount = Number(totalCountResult[0].count); + const totalPages = Math.ceil(totalCount / limit); + + // Get paginated data + const data = await this.drizzle + .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(searchCondition) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + // Build pagination metadata + const meta = { + page, + limit, + totalCount, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + nextPage: page < totalPages ? page + 1 : null, + previousPage: page > 1 ? page - 1 : null, + }; + + // console.log(data); + + return { data, meta }; + } + + async findOne(id: string): Promise { + const find = await this.drizzle + .select({ + id: users.id, + username: users.username, + email: users.email, + fullname: users.fullname, + phone: users.phone, + isActive: users.isActive, + role: roles.name, + state: schema.states.name, + municipality: schema.municipalities.name, + parish: schema.parishes.name + }) + .from(users) + .leftJoin(usersRole, eq(usersRole.userId, users.id)) + .leftJoin(roles, eq(roles.id, usersRole.roleId)) + .leftJoin(schema.states, eq(schema.states.id, users.state)) + .leftJoin(schema.municipalities, eq(schema.municipalities.id, users.municipality)) + .leftJoin(schema.parishes, eq(schema.parishes.id, users.parish)) + + .where(eq(users.id, parseInt(id))); + + if (find.length === 0) { + throw new HttpException('User does not exist', HttpStatus.BAD_REQUEST); + } + + return find[0]; + } + + // Rest of the service remains the same + async create( + createUserDto: CreateUserDto, + roleId: number = 2, + ): Promise { + // Hash the password + const hashedPassword = await bcrypt.hash(createUserDto.password, 10); + + 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); + } + } + + // 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, + phone: createUserDto.phone, + isEmailVerified: false, + isTwoFactorEnabled: false, + }) + .returning(); + + // Assign role to user + await tx.insert(usersRole).values({ + userId: newUser.id, + roleId: roleId, + }); + + // 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; + }); + } + + async update(id: string, updateUserDto: UpdateUserDto): Promise { + const userId = parseInt(id); + + // Check if user exists + await this.findOne(id); + + // Prepare update data + const updateData: any = {}; + if (updateUserDto.username) updateData.username = updateUserDto.username; + if (updateUserDto.email) updateData.email = updateUserDto.email; + if (updateUserDto.fullname) updateData.fullname = updateUserDto.fullname; + if (updateUserDto.password) { + updateData.password = await bcrypt.hash(updateUserDto.password, 10); + } + if (updateUserDto.phone) updateData.phone = updateUserDto.phone; + if (updateUserDto.isActive) updateData.isActive = updateUserDto.isActive; + + const updateDataRole: any = {}; + if (updateUserDto.role) updateDataRole.roleId = updateUserDto.role; + // Update user + await this.drizzle + .update(users) + .set(updateData) + .where(eq(users.id, userId)); + + await this.drizzle + .update(usersRole) + .set(updateDataRole) + .where(eq(usersRole.userId, userId)); + + + // Return updated user + return this.findOne(id); + } + + async updateProfile(id: string, updateUserDto: UpdateUserDto): Promise { + // throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST); + + const userId = parseInt(id); + + // Check if user exists + await this.findOne(id); + + const data = await this.drizzle + .select({ + id: users.id, + email: users.email + }) + .from(users) + .where(and( + not(eq(users.id, userId)), + eq(users.email, updateUserDto.email) + ) + ) + + if (data.length > 0) { + if (data[0].email === updateUserDto.email) { + throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST); + } + } + + // Prepare update data + const updateData: any = {}; + // if (updateUserDto.username) updateData.username = updateUserDto.username; + if (updateUserDto.email) updateData.email = updateUserDto.email; + if (updateUserDto.fullname) updateData.fullname = updateUserDto.fullname; + if (updateUserDto.password) { + updateData.password = await bcrypt.hash(updateUserDto.password, 10); + } + if (updateUserDto.phone) updateData.phone = updateUserDto.phone; + if (updateUserDto.isActive) updateData.isActive = updateUserDto.isActive; + + if (updateUserDto.state) { + updateData.state = updateUserDto.state; + updateData.municipality = updateUserDto.municipality + updateData.parish = updateUserDto.parish + } + + + // Update user + await this.drizzle + .update(users) + .set(updateData) + .where(eq(users.id, userId)); + + // Return updated user + return this.findOne(id); + + } + + async remove(id: string): Promise<{ message: string, data: User }> { + const userId = parseInt(id); + + // Check if user exists + const user = await this.findOne(id); + + // Delete user (this will cascade delete related records due to foreign key constraints) + // await this.drizzle.delete(users).where(eq(users.id, userId)); + await this.drizzle.update(users).set({ isActive: false }).where(eq(users.id, userId)); + + return { message: 'User deleted successfully', data: user }; + } +} + diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..9c03b7b --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,16 @@ +import { AppModule } from '@/app.module'; +import { bootstrap } from '@/bootstrap'; +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; + +const main = async () => { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + }); + await bootstrap(app); +}; + +main().catch((error) => { + console.log(error); + process.exit(1); +}); diff --git a/apps/api/src/swagger.ts b/apps/api/src/swagger.ts new file mode 100644 index 0000000..2978220 --- /dev/null +++ b/apps/api/src/swagger.ts @@ -0,0 +1,11 @@ +import { NestExpressApplication } from '@nestjs/platform-express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +export const swagger = async (app: NestExpressApplication) => { + const swaggerConfig = new DocumentBuilder() + .setTitle('Turbo repo') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api-docs', app, document); +}; diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts new file mode 100644 index 0000000..bdb152f --- /dev/null +++ b/apps/api/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { AppModule } from '@/app.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/api/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..3602d5e --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@repo/ts-config/nestjs.json", + "compilerOptions": { + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": false, + "outDir": "./dist", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/api/uploads/.gitkeep b/apps/api/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/.env_template b/apps/web/.env_template new file mode 100644 index 0000000..9450d76 --- /dev/null +++ b/apps/web/.env_template @@ -0,0 +1,4 @@ +AUTH_URL = http://localhost:3000 +AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE= +API_URL=http://localhost:8000 + diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..8db0e06 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..5bcd4e1 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,49 @@ +### Frontend + +`SafeFetch` + +```ts +import z, { ZodSchema } from 'zod'; +import { env } from './env'; + +export const safeFetch = async >( + schema: T, + url: URL | RequestInfo, + init?: RequestInit, +): Promise<[string | null, z.TypeOf]> => { + const response: Response = await fetch(`${env.API_URL}${url}`, init); + const res = await response.json(); + + if (!response.ok) { + return [ + `HTTP error! Status: ${response.status} - ${response.statusText}`, + null, + ]; + } + + const validateFields = schema.safeParse(res); + + if (!validateFields.success) { + console.log(res); + console.log('Validation errors:', validateFields.error); + return [`Validation error: ${validateFields.error.message}`, null]; + } + + return [null, validateFields.data]; +}; +``` + +`How to use SafeFetch?` + +```ts +export const getAllUsers = async (): Promise => { + const [isError, data] = await safeFetch(GetAllUsersSchema, '/users', { + cache: 'no-store', + }); + if (isError) + return { + data: [], + }; + return data; +}; +``` diff --git a/apps/web/app/(auth)/page.tsx b/apps/web/app/(auth)/page.tsx new file mode 100644 index 0000000..c6c678e --- /dev/null +++ b/apps/web/app/(auth)/page.tsx @@ -0,0 +1,14 @@ +import { LoginForm } from '@/feactures/auth/components/sigin-view'; + + +const Page = () => { + return ( +
+
+ +
+
+ ) +}; + +export default Page; diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..62b0319 --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/lib/auth'; // Referring to the auth.ts we just created +export const { GET, POST } = handlers; diff --git a/apps/web/app/dashboard/administracion/encuestas/crear/page.tsx b/apps/web/app/dashboard/administracion/encuestas/crear/page.tsx new file mode 100644 index 0000000..2a1c9af --- /dev/null +++ b/apps/web/app/dashboard/administracion/encuestas/crear/page.tsx @@ -0,0 +1,13 @@ +import PageContainer from '@/components/layout/page-container'; +import { SurveyBuilder } from '@/feactures/surveys/components/admin/survey-builder'; + +export default function CreateSurveyPage() { + return ( + +
+

Crear Nueva Encuesta

+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/administracion/encuestas/editar/[id]/page.tsx b/apps/web/app/dashboard/administracion/encuestas/editar/[id]/page.tsx new file mode 100644 index 0000000..2733a99 --- /dev/null +++ b/apps/web/app/dashboard/administracion/encuestas/editar/[id]/page.tsx @@ -0,0 +1,13 @@ +import PageContainer from "@/components/layout/page-container"; +import { SurveyBuilder } from "@/feactures/surveys/components/admin/survey-builder"; + +export default function EditSurveyPage() { + return ( + +
+

Editar Encuesta

+ +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/app/dashboard/administracion/encuestas/page.tsx b/apps/web/app/dashboard/administracion/encuestas/page.tsx new file mode 100644 index 0000000..1d137ec --- /dev/null +++ b/apps/web/app/dashboard/administracion/encuestas/page.tsx @@ -0,0 +1,37 @@ +import PageContainer from '@/components/layout/page-container'; +import SurveysAdminList from '@/feactures/surveys/components/admin/surveys-admin-list'; +import { SurveysHeader } from '@/feactures/surveys/components/admin/surveys-header'; +import SurveysTableAction from '@/feactures/surveys/components/admin/surveys-tables/survey-table-action'; +import { searchParamsCache, serialize } from '@/feactures/surveys/utils/searchparams'; +import { SearchParams } from 'nuqs'; + +type pageProps = { + searchParams: Promise; +}; + + +export default async function SurveyAdminPage(props: pageProps) { + const searchParams = await props.searchParams; + searchParamsCache.parse(searchParams); + const key = serialize({ ...searchParams }); + + const page = Number(searchParamsCache.get('page')) || 1; + const search = searchParamsCache.get('q'); + const pageLimit = Number(searchParamsCache.get('limit')) || 10; + const type = searchParamsCache.get('type'); + + return ( + +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/administracion/usuario/page.tsx b/apps/web/app/dashboard/administracion/usuario/page.tsx new file mode 100644 index 0000000..116c593 --- /dev/null +++ b/apps/web/app/dashboard/administracion/usuario/page.tsx @@ -0,0 +1,37 @@ +import PageContainer from '@/components/layout/page-container'; +import UsersAdminList from '@/feactures/users/components/admin/users-admin-list'; +import { UsersHeader } from '@/feactures/users/components/admin/users-header'; +import UsersTableAction from '@/feactures/users/components/admin/surveys-tables/users-table-action'; +import { searchParamsCache, serialize } from '@/feactures/users/utils/searchparams'; +import { SearchParams } from 'nuqs'; + +type pageProps = { + searchParams: Promise; +}; + + +export default async function SurveyAdminPage(props: pageProps) { + const searchParams = await props.searchParams; + searchParamsCache.parse(searchParams); + const key = serialize({ ...searchParams }); + + const page = Number(searchParamsCache.get('page')) || 1; + const search = searchParamsCache.get('q'); + const pageLimit = Number(searchParamsCache.get('limit')) || 10; + const type = searchParamsCache.get('type'); + + return ( + +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/configuraciones/caja-ahorro/page.tsx b/apps/web/app/dashboard/configuraciones/caja-ahorro/page.tsx new file mode 100644 index 0000000..4344857 --- /dev/null +++ b/apps/web/app/dashboard/configuraciones/caja-ahorro/page.tsx @@ -0,0 +1,19 @@ +import PageContainer from '@/components/layout/page-container'; + +const Page = () => { + return ( + +
+ En mantenimiento +
+ +
+ //
+ //
+ + //
+ //
+ ); +}; + +export default Page; diff --git a/apps/web/app/dashboard/encuestas/[id]/responder/page.tsx b/apps/web/app/dashboard/encuestas/[id]/responder/page.tsx new file mode 100644 index 0000000..f5e6302 --- /dev/null +++ b/apps/web/app/dashboard/encuestas/[id]/responder/page.tsx @@ -0,0 +1,24 @@ +import PageContainer from '@/components/layout/page-container'; +import { getSurveyByIdAction } from '@/feactures/surveys/actions/surveys-actions'; +import { SurveyResponse } from '@/feactures/surveys/components/survey-response'; + +// La función ahora recibe 'params' con el parámetro dinámico 'id' +export default async function SurveyResponsePage({ params }: { params: { id: string } }) { + const { id } = await params; // Obtienes el id desde los params de la URL + + if (!id || id === '') { + // Maneja el caso en el que no se proporciona un id + return null; + } + + // Llamas a la función pasando el id dinámico + const data = await getSurveyByIdAction(Number(id)); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/app/dashboard/encuestas/page.tsx b/apps/web/app/dashboard/encuestas/page.tsx new file mode 100644 index 0000000..0fec60b --- /dev/null +++ b/apps/web/app/dashboard/encuestas/page.tsx @@ -0,0 +1,21 @@ +import PageContainer from '@/components/layout/page-container'; +import { SurveyList } from '@/feactures/surveys/components/survey-list'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Encuestas - Fondemi', + description: 'Listado de encuestas disponibles', +}; + +export default function SurveysPage() { + + return ( + +
+

Encuestas Disponibles

+ +
+
+ + ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/estadisticas/encuestas/page.tsx b/apps/web/app/dashboard/estadisticas/encuestas/page.tsx new file mode 100644 index 0000000..2ee30c9 --- /dev/null +++ b/apps/web/app/dashboard/estadisticas/encuestas/page.tsx @@ -0,0 +1,19 @@ +import PageContainer from '@/components/layout/page-container'; +import { SurveyStatistics } from '@/feactures/statistics/components/survey-statistics'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Estadísticas de Encuestas - Fondemi', + description: 'Análisis y estadísticas de las encuestas realizadas', +}; + +export default function SurveyStatisticsPage() { + return ( + +
+

Estadísticas de Encuestas

+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/inicio/page.tsx b/apps/web/app/dashboard/inicio/page.tsx new file mode 100644 index 0000000..d42420e --- /dev/null +++ b/apps/web/app/dashboard/inicio/page.tsx @@ -0,0 +1,11 @@ + +export default async function Page() { + + return ( + // +
+ Image +
+ //
+ ); +} diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..db51a54 --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,31 @@ +import { AppSidebar } from '@/components/layout/app-sidebar'; +import Header from '@/components/layout/header'; +import { SidebarInset, SidebarProvider } from '@repo/shadcn/sidebar'; +import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; + +export const metadata: Metadata = { + title: 'Dashboard', + description: 'Sistema integral para Cajas de Ahorro', +}; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Persisting the sidebar state in the cookie. + const cookieStore = await cookies(); + //const defaultOpen = cookieStore.get('sidebar:state')?.value === 'false'; + return ( + + + +
+ {/* page main content */} + {children} + {/* page main content ends */} + + + ); +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..8fbbe21 --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -0,0 +1,12 @@ +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; + +export default async function Dashboard() { + const session = await auth(); + + if (!session?.user) { + return redirect('/'); + } else { + redirect('/dashboard/inicio'); + } +} diff --git a/apps/web/app/dashboard/profile/page.tsx b/apps/web/app/dashboard/profile/page.tsx new file mode 100644 index 0000000..afb494d --- /dev/null +++ b/apps/web/app/dashboard/profile/page.tsx @@ -0,0 +1,17 @@ +import PageContainer from "@/components/layout/page-container"; +import {Profile} from '@/feactures/users/components/user-profile' + + +export default function ProfilePage() { + + return ( + +
+

Perfil

+

Aquí puede ver y editar sus datos de perfil

+ +
+
+ + ); +} \ No newline at end of file diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..929d238 --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Button } from '@repo/shadcn/button'; +import { RotateCw } from '@repo/shadcn/icon'; +import { cn } from '@repo/shadcn/lib/utils'; +import { useRouter } from 'next/navigation'; +import { useEffect, useTransition } from 'react'; + +const Error = ({ error, reset }: { error: Error; reset: () => void }) => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+

+ Oh no, something went wrong... maybe refresh? +

+ +
+ ); +}; + +export default Error; diff --git a/apps/web/app/fonts/GeistMonoVF.woff b/apps/web/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/apps/web/app/fonts/GeistMonoVF.woff differ diff --git a/apps/web/app/fonts/GeistVF.woff b/apps/web/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/apps/web/app/fonts/GeistVF.woff differ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..6c53025 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,64 @@ +import Providers from '@/components/layout/providers'; +import { auth } from '@/lib/auth'; +import { cn } from '@repo/shadcn/lib/utils'; +import '@repo/shadcn/shadcn.css'; +import { Metadata } from 'next'; +import localFont from 'next/font/local'; +import NextTopLoader from 'nextjs-toploader'; +import { ReactNode } from 'react'; +import { Toaster } from 'sonner'; + +const GeistSans = localFont({ + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', +}); +const GeistMono = localFont({ + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', +}); + +export const metadata = { + metadataBase: new URL('https://turbo-npn.onrender.com'), + title: { + default: 'Caja de Ahorro', + template: '%s | Caja de Ahorro', + }, + openGraph: { + type: 'website', + title: 'Caja de Ahorro', + description: 'Sistema integral para cajas de ahorro', + url: 'https://turbo-npn.onrender.com', + images: [ + { + url: '/og-bg.png', + width: 1200, + height: 628, + alt: 'Turbo NPN Logo', + }, + ], + }, +} satisfies Metadata; + +const RootLayout = async ({ + children, +}: Readonly<{ + children: ReactNode; +}>) => { + const session = await auth(); + return ( + + + + + + {children} + + + + ); +}; + +export default RootLayout; diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..369f635 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,29 @@ +'use client'; +import { Button } from '@repo/shadcn/button'; +import { RotateCw } from '@repo/shadcn/icon'; +import { cn } from '@repo/shadcn/lib/utils'; +import { useRouter } from 'next/navigation'; +import { useTransition } from 'react'; + +const NotFound = () => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + return ( +
+

404 | Notfound

+

{"Oh no! This page doesn't exist."}

+ +
+ ); +}; + +export default NotFound; diff --git a/apps/web/app/og/mono.ttf b/apps/web/app/og/mono.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/apps/web/app/og/mono.ttf differ diff --git a/apps/web/app/og/route.tsx b/apps/web/app/og/route.tsx new file mode 100644 index 0000000..42429c5 --- /dev/null +++ b/apps/web/app/og/route.tsx @@ -0,0 +1,62 @@ +import { ImageResponse } from 'next/og'; +import { NextRequest } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl; + const title = searchParams.get('title'); + const description = searchParams.get('description'); + const font = fetch(new URL('./mono.ttf', import.meta.url)).then((res) => + res.arrayBuffer(), + ); + const fontData = await font; + + return new ImageResponse( + ( +
+
+
+
+
+
+
+
20 ? 64 : 80, + letterSpacing: '-0.04em', + }} + > + {title} +
+
+ {`${description?.slice(0, 100)}`} +
+
+
+ ), + { + width: 1200, + height: 628, + fonts: [ + { + name: 'Jetbrains Mono', + data: fontData, + style: 'normal', + }, + ], + }, + ); +} diff --git a/apps/web/app/opengraph-image.tsx b/apps/web/app/opengraph-image.tsx new file mode 100644 index 0000000..c809e2b --- /dev/null +++ b/apps/web/app/opengraph-image.tsx @@ -0,0 +1,48 @@ +import { ImageResponse } from 'next/og'; + +export const runtime = 'edge'; + +// Image metadata +export const alt = `Opengraph Image`; +export const size = { + width: 800, + height: 400, +}; + +export const contentType = 'image/png'; + +export default async function Image() { + return new ImageResponse( + ( +
+ opengraph logo +
+ ), + { + ...size, + }, + ); +} diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx new file mode 100644 index 0000000..603b7c3 --- /dev/null +++ b/apps/web/app/register/page.tsx @@ -0,0 +1,13 @@ +import { LoginForm } from "@/feactures/auth/components/signup-view"; + +const Page = () => { + return ( +
+
+ +
+
+ ) +} + +export default Page; \ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..d8b7a3e --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/shadcn/src/shadcn.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "lib": "@/lib", + "hooks": "@/hooks", + "ui": "../../packages/shadcn/components/ui", + "utils": "../../packages/shadcn/lib/utils" + } +} diff --git a/apps/web/components/breadcrumbs.tsx b/apps/web/components/breadcrumbs.tsx new file mode 100644 index 0000000..04939ba --- /dev/null +++ b/apps/web/components/breadcrumbs.tsx @@ -0,0 +1,42 @@ +'use client'; +import { useBreadcrumbs } from '@/hooks/use-breadcrumbs'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@repo/shadcn/breadcrumb'; +import { Slash } from 'lucide-react'; +import { Fragment } from 'react'; + +export function Breadcrumbs() { + const items = useBreadcrumbs(); + if (items.length === 0) return null; + return ( + + + {items.map((item, index) => ( + + {index !== items.length - 1 && ( + + {item.title} + + )} + {index < items.length - 1 && ( + + + + )} + {index === items.length - 1 && ( + + {item.title} + + )} + + ))} + + + ); +} diff --git a/apps/web/components/icons.tsx b/apps/web/components/icons.tsx new file mode 100644 index 0000000..74a63bb --- /dev/null +++ b/apps/web/components/icons.tsx @@ -0,0 +1,92 @@ +import { + AlertTriangle, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + CircuitBoardIcon, + Command, + CreditCard, + File, + FileText, + HelpCircle, + Image, + Laptop, + LayoutDashboardIcon, + Loader2, + LogIn, + LucideIcon, + LucideProps, + LucideShoppingBag, + Moon, + MoreVertical, + Pizza, + Plus, + Settings, + SunMedium, + Trash, + Twitter, + User, + UserCircle2Icon, + UserPen, + UserX2Icon, + X, + Settings2, + ChartColumn, + NotepadText +} from 'lucide-react'; + +export type Icon = LucideIcon; + +export const Icons = { + dashboard: LayoutDashboardIcon, + logo: Command, + login: LogIn, + close: X, + product: LucideShoppingBag, + spinner: Loader2, + kanban: CircuitBoardIcon, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + employee: UserX2Icon, + post: FileText, + page: File, + userPen: UserPen, + user2: UserCircle2Icon, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + settings2: Settings2, + chartColumn: ChartColumn, + notepadText: NotepadText, + gitHub: ({ ...props }: LucideProps) => ( + + ), + twitter: Twitter, + check: Check +}; diff --git a/apps/web/components/layout/ThemeToggle/theme-provider.tsx b/apps/web/components/layout/ThemeToggle/theme-provider.tsx new file mode 100644 index 0000000..304b89c --- /dev/null +++ b/apps/web/components/layout/ThemeToggle/theme-provider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { + ThemeProvider as NextThemesProvider, + ThemeProviderProps +} from 'next-themes'; + +export default function ThemeProvider({ + children, + ...props +}: ThemeProviderProps) { + return {children}; +} diff --git a/apps/web/components/layout/ThemeToggle/theme-toggle.tsx b/apps/web/components/layout/ThemeToggle/theme-toggle.tsx new file mode 100644 index 0000000..1024860 --- /dev/null +++ b/apps/web/components/layout/ThemeToggle/theme-toggle.tsx @@ -0,0 +1,37 @@ +'use client'; +import { MoonIcon, SunIcon } from 'lucide-react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@repo/shadcn/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@repo/shadcn/dropdown-menu'; +type CompProps = {}; +export default function ThemeToggle({}: CompProps) { + const { setTheme } = useTheme(); + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ); +} diff --git a/apps/web/components/layout/app-sidebar.tsx b/apps/web/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..cdeffca --- /dev/null +++ b/apps/web/components/layout/app-sidebar.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { NavMain as ConfigMain, NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main'; +import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/data'; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarRail, +} from '@repo/shadcn/sidebar'; +import { GalleryVerticalEnd } from 'lucide-react'; +import * as React from 'react'; +// import { NavItem } from '@/types'; +import { useSession } from 'next-auth/react'; + + +export const company = { + name: 'Sistema', + logo: GalleryVerticalEnd, + plan: 'FONDEMI', +}; + + + +export function AppSidebar({ ...props }: React.ComponentProps) { + const { data: session } = useSession(); + const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :''; + // console.log(AdministrationItems[0]?.role); + + return ( + + +
+
+ +
+
+ {company.name} + {company.plan} +
+
+
+ + + + {StatisticsItems[0]?.role?.includes(userRole) && + + } + + {AdministrationItems[0]?.role?.includes(userRole) && + + } + {/* */} + + +
+ ); +} diff --git a/apps/web/components/layout/header.tsx b/apps/web/components/layout/header.tsx new file mode 100644 index 0000000..d8a004d --- /dev/null +++ b/apps/web/components/layout/header.tsx @@ -0,0 +1,25 @@ +import { Separator } from '@repo/shadcn/separator'; +import { SidebarTrigger } from '@repo/shadcn/sidebar'; +import { Breadcrumbs } from '../breadcrumbs'; +import ThemeToggle from './ThemeToggle/theme-toggle'; +import { UserNav } from './user-nav'; + +export default function Header() { + return ( +
+
+ + + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/components/layout/page-container.tsx b/apps/web/components/layout/page-container.tsx new file mode 100644 index 0000000..4e579c5 --- /dev/null +++ b/apps/web/components/layout/page-container.tsx @@ -0,0 +1,22 @@ +import { ScrollArea } from '@repo/shadcn/scroll-area'; +import React from 'react'; + +export default function PageContainer({ + children, + scrollable = true, +}: { + children: React.ReactNode; + scrollable?: boolean; +}) { + return ( + <> + {scrollable ? ( + +
{children}
+
+ ) : ( +
{children}
+ )} + + ); +} diff --git a/apps/web/components/layout/providers.tsx b/apps/web/components/layout/providers.tsx new file mode 100644 index 0000000..b4b5a57 --- /dev/null +++ b/apps/web/components/layout/providers.tsx @@ -0,0 +1,48 @@ +'use client'; +import { ThemeProvider } from '@repo/shadcn/themes-provider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SessionProvider, SessionProviderProps } from 'next-auth/react'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; +import { ReactNode } from 'react'; + +type ProvidersProps = { + children: ReactNode; +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + gcTime: 60 * 60 * 1000, // 1 hora para garbage collection + staleTime: 60 * 60 * 1000, // 1 hora para considerar datos obsoletos + refetchOnWindowFocus: false, // No recargar al enfocar la ventana + refetchOnMount: true, // Recargar al montar el componente + }, + }, +}); +const Providers = ({ + session, + children, +}: { + session: SessionProviderProps['session']; + children: ReactNode; +}) => { + return ( + <> + + + + {children} + + + + + ); +}; + +export default Providers; diff --git a/apps/web/components/layout/user-nav.tsx b/apps/web/components/layout/user-nav.tsx new file mode 100644 index 0000000..3360c1a --- /dev/null +++ b/apps/web/components/layout/user-nav.tsx @@ -0,0 +1,52 @@ +'use client'; +import { Avatar, AvatarFallback, AvatarImage } from '@repo/shadcn/avatar'; +import { Button } from '@repo/shadcn/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@repo/shadcn/dropdown-menu'; +import { signOut, useSession } from 'next-auth/react'; +import { redirect } from 'next/navigation'; + +export function UserNav() { + const { data: session } = useSession(); + if (session) { + return ( + + + + + + +
+

+ {session.user?.fullname} +

+

+ {session.user?.email} +

+
+
+ + + redirect('/dashboard/profile')}>Perfil + + + signOut()}> + Cerrar Sessión + +
+
+ ); + } +} diff --git a/apps/web/components/modal/alert-modal.tsx b/apps/web/components/modal/alert-modal.tsx new file mode 100644 index 0000000..4e35f1b --- /dev/null +++ b/apps/web/components/modal/alert-modal.tsx @@ -0,0 +1,50 @@ +'use client'; +import { Button } from '@repo/shadcn/button'; +import { Modal } from '@repo/shadcn/modal'; +import { useEffect, useState } from 'react'; + +interface AlertModalProps { + isOpen: boolean; + title?: string; + description?: string; + onClose: () => void; + onConfirm: () => void; + loading: boolean; +} + +export const AlertModal: React.FC = ({ + title = 'Are you sure?', + description = 'This action cannot be undone.', + isOpen, + onClose, + onConfirm, + loading, +}) => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + +
+ + +
+
+ ); +}; diff --git a/apps/web/components/nav-main.tsx b/apps/web/components/nav-main.tsx new file mode 100644 index 0000000..e9b3d21 --- /dev/null +++ b/apps/web/components/nav-main.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@repo/shadcn/collapsible'; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from '@repo/shadcn/sidebar'; +import { ChevronRightIcon } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Icons } from './icons'; +// import { useSession } from 'next-auth/react'; + +export function NavMain({ + titleGroup, + items, + role +}: { + titleGroup: string, + role: string, + items: { + title: string; + url: string; + icon?: keyof typeof Icons; + isActive?: boolean; + items?: { + title: string; + url: string; + icon?: keyof typeof Icons; + role?: string[]; + }[]; + }[]; +}) { + const pathname = usePathname(); + // const { data: session } = useSession(); + + // const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :''; + + // console.log(session?.user.role[0]?.rol); + return ( + + + {titleGroup} + + {items.map((item) => { + const Icon = item.icon ? Icons[item.icon] : Icons.logo; + return item?.items && item?.items?.length > 0 ? ( + + + + + {item.icon && } + {item.title} + + + + + + + {item.items?.map((subItem) => ( + subItem.role?.includes(role) && + + + + {subItem.title} + + + + ))} + + + + + ) : ( + + + + + {item.title} + + + + ); + })} + + + ); +} diff --git a/apps/web/components/nav-projects.tsx b/apps/web/components/nav-projects.tsx new file mode 100644 index 0000000..327469f --- /dev/null +++ b/apps/web/components/nav-projects.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { + Folder, + Forward, + Frame, + PanelLeft, + PieChart, + Trash2, + type LucideIcon, +} from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@repo/shadcn/dropdown-menu'; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@repo/shadcn/sidebar'; + +const data = { + projects: [ + { + name: 'Design Engineering', + url: '#', + icon: Frame, + }, + { + name: 'Sales & Marketing', + url: '#', + icon: PieChart, + }, + { + name: 'Travel', + url: '#', + icon: Map, + }, + ], +}; + +export function NavProjects({ + projects, +}: { + projects: { + name: string; + url: string; + icon: LucideIcon; + }[]; +}) { + const { isMobile } = useSidebar(); + + return ( + + Projects + + {projects.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + View Project + + + + Share Project + + + + + Delete Project + + + + + ))} + + + + More + + + + + ); +} diff --git a/apps/web/components/team-switcher.tsx b/apps/web/components/team-switcher.tsx new file mode 100644 index 0000000..d68e480 --- /dev/null +++ b/apps/web/components/team-switcher.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { + AudioWaveform, + ChevronsUpDownIcon, + Command, + GalleryVerticalEnd, + PlusIcon, +} from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '@repo/shadcn/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@repo/shadcn/sidebar'; +import * as React from 'react'; + +const data = { + teams: [ + { + name: 'Acme Inc', + logo: GalleryVerticalEnd, + plan: 'Enterprise', + }, + { + name: 'Acme Corp.', + logo: AudioWaveform, + plan: 'Startup', + }, + { + name: 'Evil Corp.', + logo: Command, + plan: 'Free', + }, + ], +}; + +export function TeamSwitcher({ + teams, +}: { + teams: { + name: string; + logo: React.ElementType; + plan: string; + }[]; +}) { + const { isMobile } = useSidebar(); + const [activeTeam, setActiveTeam] = React.useState(teams[0]); + + if (!activeTeam) { + return null; + } + + return ( + + + + + +
+ +
+
+ {activeTeam.name} + {activeTeam.plan} +
+ +
+
+ + + Teams + + {teams.map((team, index) => ( + setActiveTeam(team)} + className="gap-2 p-2" + > +
+ +
+ {team.name} + ⌘{index + 1} +
+ ))} + + +
+ +
+
Add team
+
+
+
+
+
+ ); +} diff --git a/apps/web/constants/data.ts b/apps/web/constants/data.ts new file mode 100644 index 0000000..72ce81c --- /dev/null +++ b/apps/web/constants/data.ts @@ -0,0 +1,74 @@ +import { NavItem } from '@/types'; + +//Info: The following data is used for the sidebar navigation and Cmd K bar. +export const GeneralItems: NavItem[] = [ + { + title: 'Encuestas', + url: '/dashboard/encuestas/', + icon: 'notepadText', + shortcut: ['p', 'p'], + isActive: false, + items: [], // No child items + }, + +]; + + +export const AdministrationItems: NavItem[] = [ + { + title: 'Administracion', + url: '#', // Placeholder as there is no direct link for the parent + icon: 'settings2', + isActive: true, + role:['admin','superadmin','manager','user'], // sumatoria de los roles que si tienen acceso + + items: [ + { + title: 'Usuarios', + url: '/dashboard/administracion/usuario', + icon: 'userPen', + shortcut: ['m', 'm'], + role:['admin','superadmin'], + }, + { + title: 'Encuestas', + shortcut: ['l', 'l'], + url: '/dashboard/administracion/encuestas', + icon: 'login', + role:['admin','superadmin','manager','user'], + }, + ], + }, +]; + +export const StatisticsItems: NavItem[] = [ + { + title: 'Estadísticas', + url: '#', // Placeholder as there is no direct link for the parent + icon: 'chartColumn', + isActive: true, + role:['admin','superadmin','autoridad'], + + items: [ + // { + // title: 'Usuarios', + // url: '/dashboard/estadisticas/usuarios', + // icon: 'userPen', + // shortcut: ['m', 'm'], + // role:['admin','superadmin','autoridad'], + // }, + { + title: 'Encuestas', + shortcut: ['l', 'l'], + url: '/dashboard/estadisticas/encuestas', + icon: 'notepadText', + role:['admin','superadmin','autoridad'], + }, + ], + }, +]; + + + + + diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..fdd04a6 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,4 @@ +import { nextJsConfig } from '@repo/eslint-config/next-js'; + +/** @type {import("eslint").Linter.Config} */ +export default nextJsConfig; diff --git a/apps/web/feactures/auth/actions/login-action.ts b/apps/web/feactures/auth/actions/login-action.ts new file mode 100644 index 0000000..23caf96 --- /dev/null +++ b/apps/web/feactures/auth/actions/login-action.ts @@ -0,0 +1,17 @@ +'use server'; +import { safeFetchApi } from '@/lib'; +import { loginResponseSchema, UserFormValue } from '../schemas/login'; + +export const SignInAction = async (payload: UserFormValue) => { + const [error, data] = await safeFetchApi( + loginResponseSchema, + '/auth/sign-in', + 'POST', + payload, + ); + if (error) { + return error; + } else { + return data; + } +}; diff --git a/apps/web/feactures/auth/actions/refresh-token-action.ts b/apps/web/feactures/auth/actions/refresh-token-action.ts new file mode 100644 index 0000000..316d5ae --- /dev/null +++ b/apps/web/feactures/auth/actions/refresh-token-action.ts @@ -0,0 +1,20 @@ +'use server'; +import { safeFetchApi } from '@/lib'; +import { + RefreshTokenResponseSchema, + RefreshTokenValue, +} from '../schemas/refreshToken'; + +export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => { + const [error, data] = await safeFetchApi( + RefreshTokenResponseSchema, + '/auth/refreshToken', + 'POST', + refreshToken, + ); + if (error) { + console.error('Error:', error); + } else { + return data; + } +}; diff --git a/apps/web/feactures/auth/actions/register.ts b/apps/web/feactures/auth/actions/register.ts new file mode 100644 index 0000000..ee62723 --- /dev/null +++ b/apps/web/feactures/auth/actions/register.ts @@ -0,0 +1,27 @@ +'use server'; +import { safeFetchApi } from '@/lib/fetch.api'; +import { createUserValue, UsersMutate } from '../schemas/register'; + +export const registerUserAction = async (payload: createUserValue) => { + const { confirmPassword, ...payloadWithoutId } = payload; + + const [error, data] = await safeFetchApi( + UsersMutate, + '/auth/sing-up', + 'POST', + payloadWithoutId, + ); + + if (error) { + // console.error(error); + if (error.message === 'Username already exists') { + throw new Error('Ese usuario ya existe'); + } + if (error.message === 'Email already exists') { + throw new Error('Ese correo ya está en uso'); + } + throw new Error('Error al crear el usuario'); + } + + return payloadWithoutId; +}; \ No newline at end of file diff --git a/apps/web/feactures/auth/components/sigin-view.tsx b/apps/web/feactures/auth/components/sigin-view.tsx new file mode 100644 index 0000000..4ff9f94 --- /dev/null +++ b/apps/web/feactures/auth/components/sigin-view.tsx @@ -0,0 +1,35 @@ +import { + Card, + CardContent, +} from '@repo/shadcn/card'; + +import { cn } from '@repo/shadcn/lib/utils'; +import UserAuthForm from './user-auth-form'; + +export function LoginForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<'div'>) { + + return ( +
+ + + +
+ Image +
+
+
+ {/*
+ By clicking continue, you agree to our Terms of Service{" "} + and Privacy Policy. +
*/} +
+ ) + +} diff --git a/apps/web/feactures/auth/components/signup-view.tsx b/apps/web/feactures/auth/components/signup-view.tsx new file mode 100644 index 0000000..93543ed --- /dev/null +++ b/apps/web/feactures/auth/components/signup-view.tsx @@ -0,0 +1,32 @@ +import { + Card, + CardContent, +} from '@repo/shadcn/card'; + +import { cn } from '@repo/shadcn/lib/utils'; +// import UserAuthForm from './user-auth-form'; +import UserAuthForm from './user-register-form'; + +export function LoginForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<'div'>) { + + return ( +
+ + + + {/*
+ Image +
*/} +
+
+
+ ) + +} diff --git a/apps/web/feactures/auth/components/user-auth-form.tsx b/apps/web/feactures/auth/components/user-auth-form.tsx new file mode 100644 index 0000000..4cec084 --- /dev/null +++ b/apps/web/feactures/auth/components/user-auth-form.tsx @@ -0,0 +1,139 @@ +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@repo/shadcn/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@repo/shadcn/form'; +import { Input } from '@repo/shadcn/input'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { UserFormValue, formSchema } from '../schemas/login'; + +export default function UserAuthForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl'); + const [loading, startTransition] = useTransition(); + const [error, SetError] = useState(null); + const defaultValues = { + username: '', + password: '', + }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues, + }); + + const onSubmit = async (data: UserFormValue) => { + SetError(null); // Limpia cualquier error previo al intentar iniciar sesión + startTransition(async () => { + try { + const login = await signIn('credentials', { + username: data.username, + password: data.password, + redirect: false, // No queremos una redirección automática aquí + }); + + + + if (login?.error) { + const errorMessage = + login.error === 'CredentialsSignin' + ? 'Usuario o contraseña incorrectos' + : 'Contacte al Administrador'; + SetError(errorMessage); + toast.error(errorMessage); + } + + // Si la autenticación es exitosa y `redirect: false`, necesitamos redirigir manualmente + if (login?.ok && !login?.error) { + toast.success('Ingreso Exitoso!'); + router.push(callbackUrl ?? '/dashboard'); + } + } catch (error) { + console.error('Error durante el inicio de sesión:', error); + toast.error('Hubo un error inesperado'); + } + }); + }; + + return ( + <> +
+ + +
+
+

Sistema Integral Fondemi

+

+ Ingresa tus datos +

+
+
+ ( + + Usuario + + + + + + )} + /> +
+
+ + ( + + Contraseña + + + + + + )} + /> + +
+ {error && ( + {error} + )}{' '} + +
+ No tienes una cuenta?{" "} + + Registrate + +
+
+
+ + + ); +} diff --git a/apps/web/feactures/auth/components/user-register-form.tsx b/apps/web/feactures/auth/components/user-register-form.tsx new file mode 100644 index 0000000..98e485e --- /dev/null +++ b/apps/web/feactures/auth/components/user-register-form.tsx @@ -0,0 +1,321 @@ +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@repo/shadcn/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@repo/shadcn/form'; +import { Input } from '@repo/shadcn/input'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { SelectSearchable } from '@repo/shadcn/select-searchable' + +import { createUserValue, createUser } from '../schemas/register'; +import { useRegisterUser } from "../hooks/use-mutation-users"; + +import React from 'react'; +import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location'; + +export default function UserAuthForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl'); + const [loading, startTransition] = useTransition(); + const [error, SetError] = useState(null); + + const [state, setState] = React.useState(0); + const [municipality, setMunicipality] = React.useState(0); + const [parish, setParish] = React.useState(0); + + const [disabledMunicipality, setDisabledMunicipality] = React.useState(true); + const [disabledParish, setDisabledParish] = React.useState(true); + + const { data : dataState } = useStateQuery() + const { data : dataMunicipality } = useMunicipalityQuery(state) + const { data : dataParish } = useParishQuery(municipality) + + const stateOptions = dataState?.data || [{id:0,name:'Sin estados'}] + const municipalityOptions = dataMunicipality?.data || [{id:0,stateId:0,name:'Sin Municipios'}] + const parishOptions = dataParish?.data || [{id:0,municipalityId:0,name:'Sin Parroquias'}] + + + const defaultValues = { + username: '', + password: '', + confirmPassword: '', + fullname: '', + lastname: '', + email: '', + phone: '', + role: 5 + }; + const form = useForm({ + resolver: zodResolver(createUser), + defaultValues, + }); + + const { + mutate: saveAccountingAccounts, + isPending: isSaving, + isError, + } = useRegisterUser(); + + const onSubmit = async (data: createUserValue) => { + SetError(null); + + const formData = {role: 5, ...data } + + saveAccountingAccounts(formData, { + onSuccess: () => { + form.reset(); + toast.success('Registro Exitoso!'); + router.push(callbackUrl ?? '/'); + }, + onError: (e) => { + // form.setError('root', { + // type: 'manual', + // message: 'Error al guardar la cuenta contable', + // }); + SetError(e.message); + toast.error(e.message); + }, + }) + } + + return ( + <> +
+ + +
+
+

Sistema Integral Fondemi

+

+ Ingresa tus datos +

+ { error ? ( +

+ {error} +

+ ): null } +
+ +
+ + ( + + Usuario + + + + + + )} + /> + + ( + + Nombre Completo + + + + + + )} + /> + + ( + + Teléfono + + + + + + )} + /> + + ( + + Correo + + + + + + )} + /> + + ( + + Estado + + ({ + value: item.id.toString(), + label: item.name, + })) || [] + } + onValueChange={(value : any) => + {field.onChange(Number(value)); setState(value); setDisabledMunicipality(false); setDisabledParish(true)} + } + placeholder="Selecciona un estado" + defaultValue={field.value?.toString()} + // disabled={readOnly} + /> + + + )} + /> + + ( + + Municipio + + ({ + value: item.id.toString(), + label: item.name, + })) || [] + } + onValueChange={(value : any) => + {field.onChange(Number(value)); setMunicipality(value); setDisabledParish(false)} + } + placeholder="Selecciona un Municipio" + defaultValue={field.value?.toString()} + disabled={disabledMunicipality} + /> + + + )} + /> + + ( + + Parroquia + + ({ + value: item.id.toString(), + label: item.name, + })) || [] + } + onValueChange={(value : any) => + field.onChange(Number(value)) + } + placeholder="Selecciona una Parroquia" + defaultValue={field.value?.toString()} + disabled={disabledParish} + /> + + + )} + /> + + ( + + Contraseña + + + + + + )} + /> + + ( + + Repita la contraseña + + + + + + )} + /> + +
+ + {error && ( + {error} + )}{' '} + +
+ ¿Ya tienes una cuenta?{" "} + Inicia Sesión +
+
+
+ + + ); +} diff --git a/apps/web/feactures/auth/hooks/use-mutation-users.ts b/apps/web/feactures/auth/hooks/use-mutation-users.ts new file mode 100644 index 0000000..0de393c --- /dev/null +++ b/apps/web/feactures/auth/hooks/use-mutation-users.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createUserValue } from "../schemas/register"; +import { registerUserAction } from "../actions/register"; + +// Create mutation +export function useRegisterUser() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (data: createUserValue) => registerUserAction(data), + // onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }), + // onError: (e) => + }) + return mutation +} \ No newline at end of file diff --git a/apps/web/feactures/auth/schemas/login.ts b/apps/web/feactures/auth/schemas/login.ts new file mode 100644 index 0000000..119eae1 --- /dev/null +++ b/apps/web/feactures/auth/schemas/login.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +// Definir esquema de validación con Zod para el formulario +export const formSchema = z.object({ + username: z + .string() + .min(5, { message: 'Usuario debe tener minimo 5 caracteres' }), + password: z + .string() + .min(6, { message: 'La contraseña debe tener al menos 6 caracteres' }), +}); + +export type UserFormValue = z.infer; + +// Esquema para el rol +const rolSchema = z.object({ + id: z.number(), + rol: z.string(), +}); + +// Esquema para el usuario +const userSchema = z.object({ + id: z.number(), + username: z.string(), + fullname: z.string(), + email: z.string().email(), + rol: z.array(rolSchema), +}); + +// Esquema para los tokens +export const tokensSchema = z.object({ + access_token: z.string(), + access_expire_in: z.number(), + refresh_token: z.string(), + refresh_expire_in: z.number(), +}); + +// Esquema final para la respuesta del backend +export const loginResponseSchema = z.object({ + message: z.string(), + user: userSchema, + tokens: tokensSchema, +}); + +// Tipo TypeScript basado en el esquema de Zod +export type LoginResponse = z.infer; diff --git a/apps/web/feactures/auth/schemas/refreshToken.ts b/apps/web/feactures/auth/schemas/refreshToken.ts new file mode 100644 index 0000000..4590648 --- /dev/null +++ b/apps/web/feactures/auth/schemas/refreshToken.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { tokensSchema } from './login'; + +// Esquema para el refresh token +export const refreshTokenSchema = z.object({ + token: z.string(), +}); + +export type RefreshTokenValue = z.infer; + +// Esquema final para la respuesta del backend +export const RefreshTokenResponseSchema = z.object({ + tokens: tokensSchema, +}); diff --git a/apps/web/feactures/auth/schemas/register.ts b/apps/web/feactures/auth/schemas/register.ts new file mode 100644 index 0000000..5bef38e --- /dev/null +++ b/apps/web/feactures/auth/schemas/register.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +// Definir esquema de validación con Zod para el formulario +export const createUser = z.object({ + username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }), + password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }), + email: z.string().email({ message: "Correo no válido" }), + fullname: z.string(), + phone: z.string(), + state: z.number(), + municipality: z.number(), + parish: z.number(), + confirmPassword: z.string(), +}) + .refine((data) => data.password === data.confirmPassword, { + message: 'La contraseña no coincide', + path: ['confirmPassword'], +}) + +export type createUserValue = z.infer; + +export const user = z.object({ + id: z.number().optional(), + username: z.string(), + email: z.string(), + fullname: z.string(), + phone: z.string().nullable(), + isActive: z.boolean(), + role: z.string() +}); + +export const UsersMutate = z.object({ + message: z.string(), + data: user, +}) + diff --git a/apps/web/feactures/location/actions/actions.ts b/apps/web/feactures/location/actions/actions.ts new file mode 100644 index 0000000..a344717 --- /dev/null +++ b/apps/web/feactures/location/actions/actions.ts @@ -0,0 +1,36 @@ +'use server'; +import { safeFetchApi } from '@/lib/fetch.api'; +import {responseStates, responseMunicipalities, responseParishes} from '../schemas/users'; + +// import { auth } from '@/lib/auth'; + + +export const getStateAction = async () => { + const [error, response] = await safeFetchApi( + responseStates, + `/location/state/`, + 'GET' + ); + if (error) throw new Error(error.message); + return response; +}; + +export const getMunicipalityAction = async (id : number) => { + const [error, response] = await safeFetchApi( + responseMunicipalities, + `/location/municipality/${id}`, + 'GET' + ); + if (error) throw new Error(error.message); + return response; +}; + +export const getParishAction = async (id : number) => { + const [error, response] = await safeFetchApi( + responseParishes, + `/location/parish/${id}`, + 'GET' + ); + if (error) throw new Error(error.message); + return response; +}; \ No newline at end of file diff --git a/apps/web/feactures/location/hooks/use-query-location.ts b/apps/web/feactures/location/hooks/use-query-location.ts new file mode 100644 index 0000000..c9e0e36 --- /dev/null +++ b/apps/web/feactures/location/hooks/use-query-location.ts @@ -0,0 +1,16 @@ +'use client' +import { useSafeQuery } from "@/hooks/use-safe-query"; +import { getStateAction, getMunicipalityAction, getParishAction } from "../actions/actions"; + +// Hook for users +export function useStateQuery() { + return useSafeQuery(['state'], () => getStateAction()) +} + +export function useMunicipalityQuery( stateId : number ) { + return useSafeQuery(['municipality', stateId], () => getMunicipalityAction(stateId)) +} + +export function useParishQuery(municipalityId : number) { + return useSafeQuery(['parish', municipalityId], () => getParishAction(municipalityId)) +} \ No newline at end of file diff --git a/apps/web/feactures/location/schemas/users.ts b/apps/web/feactures/location/schemas/users.ts new file mode 100644 index 0000000..e35aaff --- /dev/null +++ b/apps/web/feactures/location/schemas/users.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; + +export type SurveyTable = z.infer; +export type CreateUser = z.infer; +export type UpdateUser = z.infer; + +export const user = z.object({ + id: z.number().optional(), + username: z.string(), + email: z.string(), + fullname: z.string(), + phone: z.string().nullable(), + isActive: z.boolean(), + role: z.string(), + state: z.string().optional().nullable(), + municipality: z.string().optional().nullable(), + parish: z.string().optional().nullable(), +}); + +export const createUser = z.object({ + id: z.number().optional(), + username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }), + password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }), + email: z.string().email({ message: "Correo no válido" }), + fullname: z.string(), + phone: z.string(), + confirmPassword: z.string(), + role: z.number() +}) +.refine((data) => data.password === data.confirmPassword, { + message: 'La contraseña no coincide', + path: ['confirmPassword'], +}) + +export const updateUser = z.object({ + id: z.number(), + username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }).or(z.literal('')), + password: z.string().min(6, { message: "Debe de tener 6 o más caracteres" }).or(z.literal('')), + email: z.string().email({ message: "Correo no válido" }).or(z.literal('')), + fullname: z.string().optional(), + phone: z.string().optional(), + role: z.number().optional(), + isActive: z.boolean().optional(), + state: z.number().optional().nullable(), + municipality: z.number().optional().nullable(), + parish: z.number().optional().nullable(), +}) + +export const surveysApiResponseSchema = z.object({ + message: z.string(), + data: z.array(user), + meta: z.object({ + page: z.number(), + limit: z.number(), + totalCount: z.number(), + totalPages: z.number(), + hasNextPage: z.boolean(), + hasPreviousPage: z.boolean(), + nextPage: z.number().nullable(), + previousPage: z.number().nullable(), + }), +}) + +export const states = z.object({ + id: z.number(), + name: z.string() +}) + +export const municipalities = z.object({ + id: z.number(), + stateId: z.number(), + name: z.string() +}) + +export const parishes = z.object({ + id: z.number(), + municipalityId: z.number(), + name: z.string() +}) + +export const responseStates = z.object({ + message: z.string(), + data: z.array(states), +}) + +export const responseMunicipalities = z.object({ + message: z.string(), + data: z.array(municipalities), +}) + +export const responseParishes = z.object({ + message: z.string(), + data: z.array(parishes), +}) \ No newline at end of file diff --git a/apps/web/feactures/statistics/actions/surveys-statistics-actions.ts b/apps/web/feactures/statistics/actions/surveys-statistics-actions.ts new file mode 100644 index 0000000..31bfbee --- /dev/null +++ b/apps/web/feactures/statistics/actions/surveys-statistics-actions.ts @@ -0,0 +1,27 @@ +'use server'; +import { safeFetchApi } from '@/lib/fetch.api'; +import { SurveyStatisticsData } from '../schemas/statistics'; +import { SurveyStatisticsSchema } from '../schemas/statistics-schema'; + + +export const getSurveysStatistics = async (): Promise => { + + const [error, data] = await safeFetchApi( + SurveyStatisticsSchema, + `surveys/statistics`, + 'GET', + ); + + if (error) { + console.log(error); + // console.log(error.details); + throw new Error('Ocurrio un error'); + } + + if (!data) { + throw new Error('No statistics data available'); + } + + return data?.data; +}; + diff --git a/apps/web/feactures/statistics/components/survey-details.tsx b/apps/web/feactures/statistics/components/survey-details.tsx new file mode 100644 index 0000000..ac65d34 --- /dev/null +++ b/apps/web/feactures/statistics/components/survey-details.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@repo/shadcn/select'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { SurveyStatisticsData } from '../schemas/statistics'; + +interface SurveyDetailsProps { + data: SurveyStatisticsData | undefined; +} + +export function SurveyDetails({ data }: SurveyDetailsProps) { + const [selectedSurvey, setSelectedSurvey] = useState(''); + + if (!data || !data.surveyDetails || data.surveyDetails.length === 0) { + return ( + + +

No hay datos detallados disponibles

+
+
+ ); + } + + // Set default selected survey if none is selected + if (!selectedSurvey && data.surveyDetails.length > 0) { + setSelectedSurvey(data.surveyDetails?.[0]?.id.toString() ?? ''); + } + + const currentSurvey = data.surveyDetails.find( + (survey) => survey.id.toString() === selectedSurvey + ); + + const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d', '#ffc658']; + + return ( +
+ + + Detalles por Encuesta + Análisis detallado de respuestas por encuesta + + + + + + + {currentSurvey && ( + <> +
+ + + {currentSurvey.title} + {currentSurvey.description} + + +
+
+ Total de respuestas: + {currentSurvey.totalResponses} +
+
+ Audiencia objetivo: + {currentSurvey.targetAudience} +
+
+ Fecha de creación: + {new Date(currentSurvey.createdAt).toLocaleDateString()} +
+ {currentSurvey.closingDate && ( +
+ Fecha de cierre: + {new Date(currentSurvey.closingDate).toLocaleDateString()} +
+ )} +
+
+
+ + {currentSurvey.questionStats && currentSurvey.questionStats.length > 0 && ( + + + Distribución de Respuestas + + + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="count" + nameKey="label" + > + {currentSurvey.questionStats.map((entry, index) => ( + + ))} + + [`${value}`, name]} /> + {/* */} + + + + + )} +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/feactures/statistics/components/survey-overview.tsx b/apps/web/feactures/statistics/components/survey-overview.tsx new file mode 100644 index 0000000..6233f80 --- /dev/null +++ b/apps/web/feactures/statistics/components/survey-overview.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { SurveyStatisticsData } from '../schemas/statistics'; + +interface SurveyOverviewProps { + data: SurveyStatisticsData | undefined; +} + +export function SurveyOverview({ data }: SurveyOverviewProps) { + if (!data) return null; + + const { totalSurveys, totalResponses, completionRate, surveysByMonth } = data; + + const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']; + + return ( +
+ + + Total de Encuestas + + +
{totalSurveys}
+

+ Encuestas creadas en la plataforma +

+
+
+ + + Total de Respuestas + + +
{totalResponses}
+

+ Respuestas recibidas en todas las encuestas +

+
+
+ + {/* + Tasa de Completado + + +
{totalSurveys/totalResponses}
+

+ Porcentaje de encuestas completadas +

+
*/} +
+ + + + Encuestas por Mes + Distribución de encuestas creadas por mes + + + + + + + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/apps/web/feactures/statistics/components/survey-responses.tsx b/apps/web/feactures/statistics/components/survey-responses.tsx new file mode 100644 index 0000000..894e3d9 --- /dev/null +++ b/apps/web/feactures/statistics/components/survey-responses.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { SurveyStatisticsData } from '../schemas/statistics'; + +interface SurveyResponsesProps { + data: SurveyStatisticsData | undefined; +} + +export function SurveyResponses({ data }: SurveyResponsesProps) { + if (!data) return null; + + const { responsesByAudience, responseDistribution } = data; + + const COLORS = ['#0088FE', '#8884d8', '#00C49F', '#FFBB28', '#FF8042']; + + return ( +
+ + + Respuestas por Audiencia + Distribución de respuestas según el tipo de audiencia + + + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {responsesByAudience.map((entry, index) => ( + + ))} + + [`${value} respuestas`, name]} /> + + + + + + + + + Distribución de Respuestas + Cantidad de respuestas por encuesta + + + + + + + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/apps/web/feactures/statistics/components/survey-statistics.tsx b/apps/web/feactures/statistics/components/survey-statistics.tsx new file mode 100644 index 0000000..71f759f --- /dev/null +++ b/apps/web/feactures/statistics/components/survey-statistics.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@repo/shadcn/tabs'; +import { useSurveysStatsQuery } from '../hooks/use-query-statistics'; +import { SurveyOverview } from './survey-overview'; +import { SurveyResponses } from './survey-responses'; +import { SurveyDetails } from './survey-details'; + +export function SurveyStatistics() { + const { data, isLoading } = useSurveysStatsQuery(); + + if (isLoading) { + return
Cargando estadísticas...
; + } + + return ( + + + Resumen General + Respuestas + Detalles por Encuesta + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/web/feactures/statistics/hooks/use-query-statistics.ts b/apps/web/feactures/statistics/hooks/use-query-statistics.ts new file mode 100644 index 0000000..31b4f10 --- /dev/null +++ b/apps/web/feactures/statistics/hooks/use-query-statistics.ts @@ -0,0 +1,8 @@ +import { useSafeQuery } from '@/hooks/use-safe-query'; +import { getSurveysStatistics } from '../actions/surveys-statistics-actions'; + + +// Hook for all survesys +export function useSurveysStatsQuery() { + return useSafeQuery(['surveys-statistics'], () => getSurveysStatistics()) +} diff --git a/apps/web/feactures/statistics/schemas/statistics-schema.ts b/apps/web/feactures/statistics/schemas/statistics-schema.ts new file mode 100644 index 0000000..8dc73ed --- /dev/null +++ b/apps/web/feactures/statistics/schemas/statistics-schema.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +// Esquema para QuestionStat +export const QuestionStatSchema = z.object({ + questionId: z.string(), + label: z.string(), + count: z.number(), +}); + +// Esquema para SurveyDetail +export const SurveyDetailSchema = z.object({ + id: z.number(), + title: z.string(), + description: z.string(), + totalResponses: z.number(), + targetAudience: z.string(), + createdAt: z.string(), + closingDate: z.string().optional(), + questionStats: z.array(QuestionStatSchema), +}); + + +// Esquema para SurveyStatisticsData +export const SurveyStatisticsDataSchema = z.object({ + totalSurveys: z.number(), + totalResponses: z.number(), + completionRate: z.number(), + surveysByMonth: z.array( + z.object({ + month: z.string(), + count: z.number(), + }) + ), + responsesByAudience: z.array( + z.object({ + name: z.string(), + value: z.number(), + }) + ), + responseDistribution: z.array( + z.object({ + title: z.string(), + responses: z.number(), + }) + ), + surveyDetails: z.array(SurveyDetailSchema), + // surveyDetails: z.array(z.any()), +}); + +// Response schemas for the API create, update +export const SurveyStatisticsSchema = z.object({ + message: z.string(), + data: SurveyStatisticsDataSchema, +}); + +// Tipos inferidos de Zod +export type SurveyStatisticsType = z.infer; +export type SurveyDetailType = z.infer; +export type QuestionStatType = z.infer; \ No newline at end of file diff --git a/apps/web/feactures/statistics/schemas/statistics.ts b/apps/web/feactures/statistics/schemas/statistics.ts new file mode 100644 index 0000000..d278d64 --- /dev/null +++ b/apps/web/feactures/statistics/schemas/statistics.ts @@ -0,0 +1,35 @@ +export interface SurveyStatisticsData { + totalSurveys: number; + totalResponses: number; + completionRate: number; + surveysByMonth: { + month: string; + count: number; + }[]; + responsesByAudience: { + name: string; + value: number; + }[]; + responseDistribution: { + title: string; + responses: number; + }[]; + surveyDetails: SurveyDetail[]; +} + +export interface SurveyDetail { + id: number; + title: string; + description: string; + totalResponses: number; + targetAudience: string; + createdAt: string; + closingDate?: string; + questionStats: QuestionStat[]; +} + +export interface QuestionStat { + questionId: string; + label: string; + count: number; +} \ No newline at end of file diff --git a/apps/web/feactures/surveys/actions/surveys-actions.ts b/apps/web/feactures/surveys/actions/surveys-actions.ts new file mode 100644 index 0000000..e680510 --- /dev/null +++ b/apps/web/feactures/surveys/actions/surveys-actions.ts @@ -0,0 +1,216 @@ +'use server'; +import { safeFetchApi } from '@/lib/fetch.api'; +import { SurveyAnswerMutate, Survey, SurveyResponse, surveysApiResponseSchema, suveryApiMutationResponseSchema, suveryResponseDeleteSchema, surveysApiResponseForUserSchema } from '../schemas/survey'; +import { auth } from '@/lib/auth'; + + + +const transformSurvey = (survey: any) => { + + return survey.map((survey: any) => { + return { + ...survey, + published: survey.published ? 'Publicada': 'Borrador', + } + }) +}; + + +export const getSurveysAction = async (params: { + page?: number; + limit?: number; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}) => { + + const searchParams = new URLSearchParams({ + page: (params.page || 1).toString(), + limit: (params.limit || 10).toString(), + ...(params.search && { search: params.search }), + ...(params.sortBy && { sortBy: params.sortBy }), + ...(params.sortOrder && { sortOrder: params.sortOrder }), + }); + + const [error, response] = await safeFetchApi( + surveysApiResponseSchema, + `/surveys?${searchParams}`, + 'GET', + ); + + if (error) { + console.error('Error:', error); + throw new Error(error.message); + } + + +const transformedData = response?.data ? transformSurvey(response?.data) : undefined; + + return { + data: transformedData, + meta: response?.meta || { + page: 1, + limit: 10, + totalCount: 0, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + nextPage: null, + previousPage: null, + }, + }; +}; + + +export const getSurveysForUserAction = async (params: { + page?: number; + limit?: number; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}) => { + const session = await auth() + const searchParams = new URLSearchParams({ + page: (params.page || 1).toString(), + limit: (params.limit || 10).toString(), + ...(params.search && { search: params.search }), + ...(params.sortBy && { sortBy: params.sortBy }), + ...(params.sortOrder && { sortOrder: params.sortOrder }), + }); + const rol = { + rol: session?.user.role + } + const [error, response] = await safeFetchApi( + surveysApiResponseForUserSchema, + `/surveys/for-user?${searchParams}`, + 'POST', + rol + ); + + if (error) { + console.error('Error:', error); + throw new Error(error.message); + } + + +const transformedData = response?.data ? transformSurvey(response?.data) : undefined; + + return { + data: transformedData, + meta: response?.meta || { + page: 1, + limit: 10, + totalCount: 0, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + nextPage: null, + previousPage: null, + }, + }; +}; + +export const createSurveyAction = async (payload: Survey) => { + const { id, ...payloadWithoutId } = payload; + + const [error, data] = await safeFetchApi( + suveryApiMutationResponseSchema, + '/surveys', + 'POST', + payloadWithoutId, + ); + + if (error) { + if (error.message === 'Survey already exists') { + throw new Error('Ya existe una encuesta con ese titulo'); + } + // console.error('Error:', error); + throw new Error('Error al crear la encuesta'); + } + + return data; +}; + +export const updateSurveyAction = async (payload: Survey) => { + const { id, ...payloadWithoutId } = payload; + + + const [error, data] = await safeFetchApi( + suveryApiMutationResponseSchema, + `/surveys/${id}`, + 'PATCH', + payloadWithoutId, + ); + + if (error) { + if (error.message === 'Survey already exists') { + throw new Error('Ya existe otra encuesta con ese titulo'); + } + if (error.message === 'Survey not found') { + throw new Error('No se encontró la encuesta'); + } + // console.error('Error:', error); + // throw new Error(error.message); + throw new Error('Error al actualizar la encuesta'); + } + + return data; +}; + +export const deleteSurveyAction = async (id: number) => { + const [error, data] = await safeFetchApi( + suveryResponseDeleteSchema, + `/surveys/${id}`, + 'DELETE', + ); + + if (error) { + console.error('Error:', error); + throw new Error(error.message); + } + + return data; +}; + +export const getSurveyByIdAction = async (id: number) => { + const [error, data] = await safeFetchApi( + suveryApiMutationResponseSchema, + `/surveys/${id}`, + 'GET', + ); + + if (error) { + console.error('❌ Error en la API:', error); + throw new Error(error.message); + } + + return data; +}; + +export const saveSurveysAction = async (payload: Survey) => { + try { + if (payload.id) { + return await updateSurveyAction(payload); + } else { + return await createSurveyAction(payload); + } + } catch (error: any) { + throw new Error(error.message || 'Error saving account surveys'); + } +}; + +export const saveSurveyAnswer = async (payload: SurveyResponse) => { + const [error, data] = await safeFetchApi( + SurveyAnswerMutate, + '/surveys/answers', + 'POST', + payload, + ) + + if (error) { + console.error('Error:', error); + throw new Error(error.message); + } + + return data; +} \ No newline at end of file diff --git a/apps/web/feactures/surveys/components/admin/question-config-modal.tsx b/apps/web/feactures/surveys/components/admin/question-config-modal.tsx new file mode 100644 index 0000000..bdf0bf4 --- /dev/null +++ b/apps/web/feactures/surveys/components/admin/question-config-modal.tsx @@ -0,0 +1,245 @@ +// Modal para configurar cada pregunta individual +// Funcionalidades: +// - Configuración específica según el tipo de pregunta +// - Para títulos: solo contenido +// - Para preguntas simples: texto de la pregunta +// - Para preguntas con opciones: texto y lista de opciones +// - Switch para hacer la pregunta obligatoria/opcional +'use client'; + +import { Button } from '@repo/shadcn/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@repo/shadcn/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@repo/shadcn/form'; +import { Input } from '@repo/shadcn/input'; +import { Switch } from '@repo/shadcn/switch'; +import { useEffect } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { QuestionType } from '../../schemas/survey'; +import { Plus, Trash2 } from 'lucide-react'; + +interface QuestionConfigModalProps { + isOpen: boolean; + onClose: () => void; + question: any; + onSave: (config: any) => void; +} + +export function QuestionConfigModal({ + isOpen, + onClose, + question, + onSave, +}: QuestionConfigModalProps) { + const form = useForm({ + defaultValues: { + content: '', + question: '', + required: false, + options: [{ id: '1', text: '' }], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'options', + }); + + useEffect(() => { + if (question) { + form.reset({ + content: question.content || '', + question: question.question || '', + required: question.required || false, + options: question.options || [{ id: '1', text: '' }], + }); + } + }, [question, form]); + + const handleSubmit = (data: any) => { + const config = { + ...question, + ...data, + }; + + // Remove options if not needed + if (![ + QuestionType.MULTIPLE_CHOICE, + QuestionType.SINGLE_CHOICE, + QuestionType.SELECT + ].includes(question.type)) { + delete config.options; + } + + // Remove content if not a title + if (question.type !== QuestionType.TITLE) { + delete config.content; + } + + onSave(config); + }; + + const renderFields = () => { + switch (question?.type) { + case QuestionType.TITLE: + return ( + ( + + Contenido del Título + + + + + + )} + /> + ); + + case QuestionType.SIMPLE: + return ( + ( + + Pregunta + + + + + + )} + /> + ); + + case QuestionType.MULTIPLE_CHOICE: + case QuestionType.SINGLE_CHOICE: + case QuestionType.SELECT: + return ( + <> + ( + + Pregunta + + + + + + )} + /> + +
+
+ Opciones + +
+ +
+ {fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> + {fields.length > 1 && ( + + )} +
+ ))} +
+
+ + ); + } + }; + + return ( + + +
+ Configuración de la pregunta de la encuesta +
+ + Configurar Pregunta + + +
+ + {renderFields()} + + {question?.type !== QuestionType.TITLE && ( + ( + +
+ Respuesta Obligatoria +
+ + + +
+ )} + /> + )} + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/feactures/surveys/components/admin/question-toolbox.tsx b/apps/web/feactures/surveys/components/admin/question-toolbox.tsx new file mode 100644 index 0000000..7bd96ba --- /dev/null +++ b/apps/web/feactures/surveys/components/admin/question-toolbox.tsx @@ -0,0 +1,88 @@ +// Caja de herramientas con tipos de preguntas disponibles +// Funcionalidades: +// - Lista de elementos arrastrables +// - Tipos disponibles: Título, Pregunta Simple, Opción Múltiple, Opción Única, Selección +// - Cada elemento es arrastrable al área de construcción +'use client'; + +import { Card, CardContent } from '@repo/shadcn/card'; +import { QuestionType } from '../../schemas/survey'; +import { useDraggable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; + +const questionTypes = [ + { + type: QuestionType.TITLE, + label: 'Título', + icon: '📝', + }, + { + type: QuestionType.SIMPLE, + label: 'Pregunta Simple', + icon: '✏️', + }, + { + type: QuestionType.MULTIPLE_CHOICE, + label: 'Opción Múltiple', + icon: '☑️', + }, + { + type: QuestionType.SINGLE_CHOICE, + label: 'Opción Única', + icon: '⭕', + }, + { + type: QuestionType.SELECT, + label: 'Selección', + icon: '📋', + }, +]; + +function DraggableItem({ type, label, icon }: { type: string; label: string; icon: string }) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: type, + data: { + type, + isTemplate: true, + }, + }); + + const style = transform ? { + transform: CSS.Translate.toString(transform), + } : undefined; + + return ( +
+
+ {icon} + {label} +
+
+ ); +} + +export function QuestionToolbox() { + return ( + + +

Elementos Disponibles

+
+ {questionTypes.map((item) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/feactures/surveys/components/admin/survey-builder.tsx b/apps/web/feactures/surveys/components/admin/survey-builder.tsx new file mode 100644 index 0000000..750968c --- /dev/null +++ b/apps/web/feactures/surveys/components/admin/survey-builder.tsx @@ -0,0 +1,454 @@ +// Componente principal para crear/editar encuestas +// Funcionalidades: +// - Formulario para datos básicos (título, descripción, fecha de cierre) +// - Sistema de drag & drop para agregar preguntas +// - Reordenamiento de preguntas existentes +// - Guardado como borrador o publicación directa'use client'; +'use client'; + +import { Button } from '@repo/shadcn/button'; +import { Card, CardContent } from '@repo/shadcn/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@repo/shadcn/form'; +import { Input } from '@repo/shadcn/input'; +import { Textarea } from '@repo/shadcn/textarea'; +import { CalendarIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { QuestionType, Survey } from '../../schemas/survey'; +import { QuestionConfigModal } from './question-config-modal'; +import { QuestionToolbox } from './question-toolbox'; +import { cn } from '@repo/shadcn/lib/utils'; +import { Calendar } from '@repo/shadcn/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@repo/shadcn/popover'; +import { format } from 'date-fns'; +import { DndContext, DragEndEvent, useSensor, useSensors, PointerSensor } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useDroppable } from '@dnd-kit/core'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/shadcn/select"; +import { useParams, useRouter } from 'next/navigation'; + +// Añade el import de Trash2 +import { Trash2 } from 'lucide-react'; +import { useSurveysByIdQuery } from '../../hooks/use-query-surveys'; +import { useSurveyMutation } from '../../hooks/use-mutation-surveys'; + + +function SortableQuestion({ + question, + index, + onDelete, + onEdit +}: { + question: any; + index: number; + onDelete: (id: string) => void; + onEdit: (question: any) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: question.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + +
+
onEdit(question)} + > + {question.question || question.content} +
+ +
+
+
+
+ ); +} + +function DroppableArea({ children }: { children: React.ReactNode }) { + const { setNodeRef } = useDroppable({ + id: 'questions-container', + }); + + return ( +
+ {children} +
+ ); +} + +export function SurveyBuilder() { + const [questions, setQuestions] = useState([]); + const [selectedQuestion, setSelectedQuestion] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const params = useParams(); + const router = useRouter(); + const surveyId = params?.id as string; + const isEditing = Boolean(surveyId); + + + const form = useForm({ + defaultValues: { + title: '', + description: '', + closingDate: undefined as Date | undefined, + targetAudience: '', // Nuevo campo + }, + }); + + const { + mutate: MutateSurvey, + } = useSurveyMutation() + + + // Remove the loadSurvey function and use the query hook at component level + if (isEditing) { + const { data: surveyById, isLoading } = useSurveysByIdQuery(parseInt(surveyId)) + + // Use useEffect to handle the form reset when data is available + useEffect(() => { + // console.log(isEditing ? parseInt(surveyId) : 0); + if (surveyById?.data && !isLoading) { + form.reset({ + title: surveyById.data.title, + description: surveyById.data.description, + closingDate: surveyById.data.closingDate || undefined, + targetAudience: surveyById.data.targetAudience, + }); + // Fix: Set the questions directly without wrapping in array + setQuestions(surveyById.data.questions || []); + } + }, [surveyById, isLoading, form]); + } + + + // Remove the loadSurvey() call from the component body + + + + // Procesa la configuración de una pregunta después de cerrar el modal + // Actualiza o agrega la pregunta al listado + const handleQuestionConfig = (questionConfig: any) => { + if (selectedQuestion) { + const updatedQuestions = [...questions]; + const index = updatedQuestions.findIndex(q => q.id === selectedQuestion.id); + + if (index === -1) { + updatedQuestions.push({ + ...selectedQuestion, + ...questionConfig, + }); + } else { + updatedQuestions[index] = { + ...selectedQuestion, + ...questionConfig, + }; + } + + setQuestions(updatedQuestions); + } + setIsModalOpen(false); + }; + + // Maneja el guardado de la encuesta + // Valida campos requeridos y guarda como borrador o publicada + const handleSave = async (status: 'draft' | 'published') => { + const formData = form.getValues(); + + // validar que los campos no esten vacíos + if (!formData.title) return toast.error('El título es obligatorio') + if (!formData.description) return toast.error('La descripción es obligatorio') + if (!formData.targetAudience) return toast.error('El público objetivo es obligatorio') + if (!formData.closingDate) return toast.error('La fecha de cierre es obligatorio') + if (questions.length === 0) return toast.error('Debe agregar al menos una pregunta'); + + const surveyData: Omit = { + title: formData.title, + description: formData.description, + closingDate: formData.closingDate, + targetAudience: formData.targetAudience, + published: status === 'published', + questions: questions.map((q, index) => ({ ...q, position: index })), + }; + + try { + await MutateSurvey({ + ...surveyData, + id: isEditing ? parseInt(surveyId) : undefined, + }, { + onSuccess: () => { + toast.success( + isEditing + ? 'Encuesta actualizada exitosamente' + : status === 'published' + ? 'Encuesta publicada' + : 'Encuesta guardada como borrador' + ); + router.push('/dashboard/administracion/encuestas'); + }, + onError: (e) => { + toast.error(e.message) + } + }); + } catch (error) { + toast.error( `Error al ${isEditing ? 'actualizar' : 'guardar'} la encuesta`) + } + }; + + + // Configuración de los sensores para el drag and drop + // Define la distancia mínima para activar el arrastre + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + // Manejador del evento cuando se termina de arrastrar un elemento + // Gestiona tanto nuevas preguntas como reordenamiento + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + if (active.data.current?.isTemplate) { + // Handle new question from toolbox + const questionType = active.data.current.type; + const newQuestion = { + id: `q-${questions.length + 1}`, + type: questionType as QuestionType, + position: questions.length, + required: false, + }; + setSelectedQuestion(newQuestion); + setIsModalOpen(true); + } else { + // Handle reordering of existing questions + const oldIndex = questions.findIndex(q => q.id === active.id); + const newIndex = questions.findIndex(q => q.id === over.id); + + if (oldIndex !== newIndex) { + const updatedQuestions = [...questions]; + const [movedQuestion] = updatedQuestions.splice(oldIndex, 1); + updatedQuestions.splice(newIndex, 0, movedQuestion); + setQuestions(updatedQuestions); + } + } + }; + + // Añade estas funciones manejadoras + const handleDeleteQuestion = (id: string) => { + setQuestions(questions.filter(q => q.id !== id)); + }; + + const handleEditQuestion = (question: any) => { + setSelectedQuestion(question); + setIsModalOpen(true); + }; + + return ( + +
+
+ +
+ +
+ + +
+ + ( + + Título de la Encuesta + + + + + + )} + /> + + ( + + Descripción + +