Commit c6e035bd by Augusto

Backend Nestjs Project

parents
# 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
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
{
"eslint.enable": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
}
}
\ No newline at end of file
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);
\ No newline at end of file
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "api-cellnex",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"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"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.1",
"@prisma/client": "^6.6.0",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"date-fns": "^4.1.0",
"nodemailer": "^6.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.6.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
CREATE EXTENSION IF NOT EXISTS postgis;
\ No newline at end of file
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MANAGER', 'OPERATOR', 'VIEWER');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'VIEWER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Site" (
"id" SERIAL NOT NULL,
"siteCode" TEXT NOT NULL,
"siteName" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Candidate" (
"id" SERIAL NOT NULL,
"candidateCode" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"type" TEXT NOT NULL,
"address" TEXT NOT NULL,
"comments" TEXT,
"currentStatus" TEXT NOT NULL,
"onGoing" BOOLEAN NOT NULL DEFAULT false,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Site_siteCode_key" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Site_siteCode_idx" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Candidate_candidateCode_idx" ON "Candidate"("candidateCode");
-- CreateIndex
CREATE INDEX "Candidate_currentStatus_idx" ON "Candidate"("currentStatus");
-- CreateIndex
CREATE INDEX "Candidate_siteId_idx" ON "Candidate"("siteId");
-- CreateIndex
CREATE INDEX "Candidate_onGoing_idx" ON "Candidate"("onGoing");
-- CreateIndex
CREATE UNIQUE INDEX "Candidate_siteId_candidateCode_key" ON "Candidate"("siteId", "candidateCode");
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'SUPERADMIN';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetToken" TEXT,
ADD COLUMN "resetTokenExpiry" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_token_idx" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false;
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
enum Role {
SUPERADMIN
ADMIN
MANAGER
OPERATOR
VIEWER
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String // This will store the hashed password
role Role @default(VIEWER)
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
refreshTokens RefreshToken[]
resetToken String? // For password reset
resetTokenExpiry DateTime? // Expiry time for reset token
@@index([email])
@@index([role])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidates Candidate[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
updatedById Int?
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
comments String?
currentStatus String
onGoing Boolean @default(false)
site Site @relation(fields: [siteId], references: [id])
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
updatedById Int?
@@unique([siteId, candidateCode])
@@index([candidateCode])
@@index([currentStatus])
@@index([siteId])
@@index([onGoing])
}
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './common/prisma/prisma.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { SitesModule } from './modules/sites/sites.module';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
transport: {
host: config.get('SMTP_HOST'),
port: config.get('SMTP_PORT'),
secure: config.get('SMTP_SECURE', false),
auth: {
user: config.get('SMTP_USER'),
pass: config.get('SMTP_PASS'),
},
},
defaults: {
from: config.get('SMTP_FROM', 'noreply@cellnex.com'),
},
template: {
dir: join(__dirname, 'assets/templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
inject: [ConfigService],
}),
PrismaModule,
UsersModule,
AuthModule,
SitesModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule { }
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
<h1>Password Reset Request</h1>
<p>Hello {{name}},</p>
<p>You have requested to reset your password. Click the link below to reset it:</p>
<p><a href="{{resetUrl}}">Reset Password</a></p>
<p>If you did not request this, please ignore this email.</p>
<p>This link will expire in 1 hour.</p>
<p>If the button above doesn't work, copy and paste this URL into your browser:</p>
<p>{{resetUrl}}</p>
<p>Best regards,<br>Cellnex Team</p>
\ No newline at end of file
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule { }
\ No newline at end of file
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class EmailService {
private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST'),
port: this.configService.get<number>('SMTP_PORT'),
secure: this.configService.get<boolean>('SMTP_SECURE', false),
auth: {
user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'),
},
});
}
async sendPasswordResetEmail(email: string, resetToken: string, resetUrl: string): Promise<void> {
const mailOptions = {
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'),
to: email,
subject: 'Password Reset Request',
html: `
<h1>Password Reset Request</h1>
<p>You have requested to reset your password. Click the link below to reset it:</p>
<p><a href="${resetUrl}?token=${resetToken}">Reset Password</a></p>
<p>If you did not request this, please ignore this email.</p>
<p>This link will expire in 1 hour.</p>
<p>If the button above doesn't work, copy and paste this URL into your browser:</p>
<p>${resetUrl}?token=${resetToken}</p>
<p>Best regards,<br>Cellnex Team</p>
`,
};
try {
await this.transporter.sendMail(mailOptions);
} catch (error) {
console.error('Failed to send email:', error);
throw new Error('Failed to send password reset email');
}
}
}
\ No newline at end of file
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule { }
\ No newline at end of file
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
\ No newline at end of file
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS
app.enableCors();
// Global prefix
app.setGlobalPrefix('api/');
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Swagger configuration
const config = new DocumentBuilder()
.setTitle('Cellnex API')
.setDescription('The Cellnex API for managing sites and candidates')
.setVersion('1.0')
.addTag('auth', 'Authentication endpoints')
.addTag('users', 'User management endpoints')
.addTag('sites', 'Site management endpoints')
.addTag('candidates', 'Candidate management endpoints')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'access-token', // This name here is important for matching up with @ApiBearerAuth() in your controller!
)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation is available at: http://localhost:${port}/docs`);
}
bootstrap();
import { Body, Controller, Get, Headers, Post, UnauthorizedException, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RequestPasswordResetDto } from './dto/request-password-reset.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from './decorators/public.decorator';
@ApiTags('auth')
@Controller('auth')
@Public()
export class AuthController {
constructor(private readonly authService: AuthService) { }
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({
status: 200,
description: 'Returns JWT token, refresh token and user information',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({
status: 200,
description: 'Returns new access token and refresh token',
})
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto);
}
@Post('password/reset-request')
@ApiOperation({ summary: 'Request password reset email' })
@ApiResponse({
status: 200,
description: 'Password reset email sent',
})
@ApiResponse({ status: 400, description: 'User not found' })
@HttpCode(HttpStatus.OK)
async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) {
return this.authService.requestPasswordReset(requestPasswordResetDto);
}
@Post('password/reset')
@ApiOperation({ summary: 'Reset password using reset token' })
@ApiResponse({
status: 200,
description: 'Password successfully reset',
})
@ApiResponse({ status: 400, description: 'Invalid or expired reset token' })
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.authService.resetPassword(resetPasswordDto);
}
@Get('validate')
@ApiOperation({ summary: 'Validate JWT token' })
@ApiResponse({
status: 200,
description: 'Returns user information from token',
})
@ApiResponse({ status: 401, description: 'Invalid token' })
async validateToken(@Headers('authorization') auth: string) {
if (!auth || !auth.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = auth.split(' ')[1];
return this.authService.validateToken(token);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './strategies/jwt.strategy';
import { EmailModule } from '../../common/email/email.module';
import { MailerModule } from '@nestjs-modules/mailer';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Module({
imports: [
UsersModule,
EmailModule,
PassportModule,
MailerModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
signOptions: {
expiresIn: '24h',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard],
})
export class AuthModule { }
\ No newline at end of file
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RequestPasswordResetDto } from './dto/request-password-reset.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { PrismaService } from '../../common/prisma/prisma.service';
import { Role } from '@prisma/client';
import { EmailService } from '../../common/email/email.service';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { add } from 'date-fns';
import { MailerService } from '@nestjs-modules/mailer';
import { randomBytes } from 'crypto';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private prisma: PrismaService,
private emailService: EmailService,
private configService: ConfigService,
private mailerService: MailerService,
) { }
async validateUser(email: string, password: string): Promise<any> {
const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) {
if (!user.isActive) {
throw new UnauthorizedException('Your account is not active. Please contact an administrator.');
}
const { password, ...result } = user;
return result;
}
return null;
}
async login(loginDto: LoginDto) {
const user = await this.validateUser(loginDto.email, loginDto.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const payload = {
sub: user.id,
email: user.email,
role: user.role
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: '15m',
}),
this.jwtService.signAsync(payload, {
expiresIn: '7d',
}),
]);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
access_token: accessToken,
refresh_token: refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
};
}
async refreshToken(refreshTokenDto: RefreshTokenDto) {
try {
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken);
const user = await this.usersService.findOne(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
const storedToken = await this.prisma.refreshToken.findFirst({
where: {
token: refreshTokenDto.refreshToken,
userId: user.id,
expiresAt: {
gt: new Date(),
},
},
});
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
// Delete the used refresh token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
// Generate new tokens
const newPayload = {
sub: user.id,
email: user.email,
role: user.role
};
const [newAccessToken, newRefreshToken] = await Promise.all([
this.jwtService.signAsync(newPayload, {
expiresIn: '15m',
}),
this.jwtService.signAsync(newPayload, {
expiresIn: '7d',
}),
]);
// Store new refresh token
await this.prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
}
async requestPasswordReset(requestPasswordResetDto: RequestPasswordResetDto) {
const user = await this.usersService.findByEmail(requestPasswordResetDto.email);
if (!user) {
// Return success even if user doesn't exist to prevent email enumeration
return { message: 'If your email is registered, you will receive a password reset link.' };
}
const resetToken = randomBytes(32).toString('hex');
const resetTokenExpiry = new Date();
resetTokenExpiry.setHours(resetTokenExpiry.getHours() + 1); // Token expires in 1 hour
await this.usersService.update(user.id, {
resetToken,
resetTokenExpiry,
});
const resetUrl = `${this.configService.get('FRONTEND_URL')}/reset-password?token=${resetToken}`;
await this.mailerService.sendMail({
to: user.email,
subject: 'Password Reset Request',
template: 'password-reset',
context: {
name: user.name,
resetUrl,
},
});
return { message: 'If your email is registered, you will receive a password reset link.' };
}
async resetPassword(resetPasswordDto: ResetPasswordDto) {
const user = await this.usersService.findByResetToken(resetPasswordDto.token);
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
throw new UnauthorizedException('Invalid or expired reset token');
}
const hashedPassword = await bcrypt.hash(resetPasswordDto.newPassword, 10);
await this.usersService.update(user.id, {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
});
return { message: 'Password has been reset successfully' };
}
async validateToken(token: string) {
try {
const payload = this.jwtService.verify(token);
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}
\ No newline at end of file
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
\ No newline at end of file
import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
\ No newline at end of file
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiProperty({
description: 'The email of the user',
example: 'augusto.fonte@brandit.pt',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'The password of the user',
example: 'brandit123465',
})
@IsString()
@IsNotEmpty()
password: string;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty({
description: 'The refresh token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
refreshToken: string;
}
\ No newline at end of file
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the user requesting password reset',
example: 'user@example.com',
})
@IsEmail()
@IsNotEmpty()
email: string;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty({
description: 'Reset token received via email',
example: 'abc123def456',
})
@IsString()
@IsNotEmpty()
token: string;
@ApiProperty({
description: 'New password',
example: 'newPassword123',
minLength: 8,
})
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword: string;
}
\ No newline at end of file
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: any, user: any) {
if (err || !user) {
throw err || new UnauthorizedException('Authentication required');
}
return user;
}
}
\ No newline at end of file
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { }
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}
\ No newline at end of file
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
});
}
async validate(payload: any) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}
\ No newline at end of file
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto';
@ApiTags('candidates')
@Controller('candidates')
export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { }
@Post()
@ApiOperation({ summary: 'Create a new candidate' })
@ApiResponse({ status: 201, description: 'The candidate has been successfully created.' })
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCandidateDto: CreateCandidateDto) {
return this.candidatesService.create(createCandidateDto);
}
@Get()
@ApiOperation({ summary: 'Get all candidates' })
@ApiResponse({ status: 200, description: 'Return all candidates.' })
findAll(@Query() query: QueryCandidateDto) {
return this.candidatesService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({ status: 200, description: 'Return the candidate.' })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.findOne(id);
}
@Get('site/:siteId')
@ApiOperation({ summary: 'Get candidates by site id' })
@ApiResponse({ status: 200, description: 'Return the candidates for the site.' })
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number) {
return this.candidatesService.findBySiteId(siteId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully updated.' })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCandidateDto: UpdateCandidateDto,
) {
return this.candidatesService.update(id, updateCandidateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully deleted.' })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.remove(id);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { CandidatesService } from './candidates.service';
import { CandidatesController } from './candidates.controller';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [CandidatesController],
providers: [CandidatesService],
exports: [CandidatesService],
})
export class CandidatesModule { }
\ No newline at end of file
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto';
@Injectable()
export class CandidatesService {
constructor(private prisma: PrismaService) { }
async create(createCandidateDto: CreateCandidateDto) {
return this.prisma.candidate.create({
data: createCandidateDto,
});
}
async findAll(query: QueryCandidateDto) {
const { firstName, lastName, email, siteId, page = 1, limit = 10 } = query;
const where = {
...(firstName && { firstName: { contains: firstName, mode: 'insensitive' } }),
...(lastName && { lastName: { contains: lastName, mode: 'insensitive' } }),
...(email && { email: { contains: email, mode: 'insensitive' } }),
...(siteId && { siteId }),
};
const [total, data] = await Promise.all([
this.prisma.candidate.count({ where }),
this.prisma.candidate.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const candidate = await this.prisma.candidate.findUnique({
where: { id },
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
return candidate;
}
async update(id: number, updateCandidateDto: UpdateCandidateDto) {
try {
return await this.prisma.candidate.update({
where: { id },
data: updateCandidateDto,
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
}
async remove(id: number) {
try {
return await this.prisma.candidate.delete({
where: { id },
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
}
async findBySiteId(siteId: number) {
return this.prisma.candidate.findMany({
where: { siteId },
});
}
}
\ No newline at end of file
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean, IsUUID, IsEmail, IsPhoneNumber } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType {
TOWER = 'TOWER',
ROOFTOP = 'ROOFTOP',
GROUND = 'GROUND',
OTHER = 'OTHER',
}
export enum CandidateStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
export class CreateCandidateDto {
@ApiProperty({ description: 'The first name of the candidate' })
@IsString()
firstName: string;
@ApiProperty({ description: 'The last name of the candidate' })
@IsString()
lastName: string;
@ApiProperty({ description: 'The email address of the candidate' })
@IsEmail()
email: string;
@ApiProperty({ description: 'The phone number of the candidate', required: false })
@IsOptional()
@IsPhoneNumber()
phone?: string;
@ApiProperty({ description: 'The ID of the site the candidate is associated with' })
@IsNumber()
siteId: number;
@ApiProperty({ description: 'Additional notes about the candidate', required: false })
@IsOptional()
@IsString()
notes?: string;
@ApiProperty({ description: 'Candidate code' })
@IsString()
candidateCode: string;
@ApiProperty({ description: 'Latitude coordinate' })
@IsNumber()
latitude: number;
@ApiProperty({ description: 'Longitude coordinate' })
@IsNumber()
longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
@IsEnum(CandidateType)
type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' })
@IsString()
address: string;
@ApiPropertyOptional({ description: 'Additional comments' })
@IsString()
@IsOptional()
comments?: string;
@ApiProperty({ description: 'Current status of the candidate' })
@IsEnum(CandidateStatus)
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' })
@IsBoolean()
onGoing: boolean;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEmail } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryCandidateDto {
@ApiProperty({ description: 'Filter by first name', required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ description: 'Filter by last name', required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ description: 'Filter by email', required: false })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ description: 'Filter by site ID', required: false })
@IsOptional()
@IsNumber()
siteId?: number;
@ApiProperty({ description: 'Page number for pagination', required: false, default: 1 })
@IsOptional()
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@ApiProperty({ description: 'Number of items per page', required: false, default: 10 })
@IsOptional()
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEmail, IsPhoneNumber } from 'class-validator';
export class UpdateCandidateDto {
@ApiProperty({ description: 'The first name of the candidate', required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ description: 'The last name of the candidate', required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ description: 'The email address of the candidate', required: false })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ description: 'The phone number of the candidate', required: false })
@IsOptional()
@IsPhoneNumber()
phone?: string;
@ApiProperty({ description: 'The ID of the site the candidate is associated with', required: false })
@IsOptional()
@IsNumber()
siteId?: number;
@ApiProperty({ description: 'Additional notes about the candidate', required: false })
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max } from 'class-validator';
export class CreateSiteDto {
@ApiProperty({
description: 'Unique code for the site',
example: 'SITE001',
})
@IsString()
@IsNotEmpty()
siteCode: string;
@ApiProperty({
description: 'Name of the site',
example: 'Downtown Tower',
})
@IsString()
@IsNotEmpty()
siteName: string;
@ApiProperty({
description: 'Latitude coordinate of the site',
example: 40.7128,
minimum: -90,
maximum: 90,
})
@IsNumber()
@Min(-90)
@Max(90)
latitude: number;
@ApiProperty({
description: 'Longitude coordinate of the site',
example: -74.0060,
minimum: -180,
maximum: 180,
})
@IsNumber()
@Min(-180)
@Max(180)
longitude: number;
}
\ No newline at end of file
import { PartialType } from '@nestjs/swagger';
import { CreateSiteDto } from './create-site.dto';
export class UpdateSiteDto extends PartialType(CreateSiteDto) { }
\ No newline at end of file
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { SitesService } from './sites.service';
import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
@ApiTags('sites')
@Controller('sites')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class SitesController {
constructor(private readonly sitesService: SitesService) { }
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Create a new site' })
@ApiResponse({
status: 201,
description: 'The site has been successfully created.',
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' })
create(@Body() createSiteDto: CreateSiteDto, @User('id') userId: number) {
return this.sitesService.create(createSiteDto, userId);
}
@Get()
@ApiOperation({ summary: 'Get all sites' })
@ApiResponse({
status: 200,
description: 'Return all sites.',
})
findAll() {
return this.sitesService.findAll();
}
@Get('code/:siteCode')
@ApiOperation({ summary: 'Get a site by code' })
@ApiResponse({
status: 200,
description: 'Return the site.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findByCode(@Param('siteCode') siteCode: string) {
return this.sitesService.findByCode(siteCode);
}
@Get(':id')
@ApiOperation({ summary: 'Get a site by id' })
@ApiResponse({
status: 200,
description: 'Return the site.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.findOne(id);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update a site' })
@ApiResponse({
status: 200,
description: 'The site has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateSiteDto: UpdateSiteDto,
@User('id') userId: number,
) {
return this.sitesService.update(id, updateSiteDto, userId);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a site' })
@ApiResponse({
status: 200,
description: 'The site has been successfully deleted.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.remove(id);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { SitesService } from './sites.service';
import { SitesController } from './sites.controller';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [SitesController],
providers: [SitesService],
exports: [SitesService],
})
export class SitesModule { }
\ No newline at end of file
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto';
@Injectable()
export class SitesService {
constructor(private prisma: PrismaService) { }
async create(createSiteDto: CreateSiteDto, userId: number) {
try {
return await this.prisma.site.create({
data: {
...createSiteDto,
createdById: userId,
updatedById: userId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException(`Site with code ${createSiteDto.siteCode} already exists`);
}
throw error;
}
}
async findAll() {
return this.prisma.site.findMany({
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
_count: {
select: {
candidates: true,
},
},
},
});
}
async findOne(id: number) {
const site = await this.prisma.site.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
candidates: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
_count: {
select: {
candidates: true,
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
return site;
}
async update(id: number, updateSiteDto: UpdateSiteDto, userId: number) {
try {
return await this.prisma.site.update({
where: { id },
data: {
...updateSiteDto,
updatedById: userId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
if (error.code === 'P2002') {
throw new ConflictException(`Site with code ${updateSiteDto.siteCode} already exists`);
}
throw error;
}
}
async remove(id: number) {
try {
await this.prisma.site.delete({
where: { id },
});
return { message: `Site with ID ${id} has been deleted` };
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
throw error;
}
}
async findByCode(siteCode: string) {
const site = await this.prisma.site.findUnique({
where: { siteCode },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
_count: {
select: {
candidates: true,
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with code ${siteCode} not found`);
}
return site;
}
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { Role } from '@prisma/client';
export class CreateUserDto {
@ApiProperty({
description: 'The email of the user',
example: 'john.doe@example.com',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'The name of the user',
example: 'John Doe',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
description: 'The password of the user',
example: 'password123',
minLength: 8,
})
@IsString()
@IsNotEmpty()
@MinLength(8)
password: string;
@ApiProperty({
description: 'The role of the user',
enum: Role,
default: Role.VIEWER,
example: Role.VIEWER,
})
@IsEnum(Role)
role: Role = Role.VIEWER;
}
\ No newline at end of file
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
import { IsOptional, IsString, IsDate } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiProperty({ required: false, description: 'Password reset token' })
@IsOptional()
@IsString()
resetToken?: string | null;
@ApiProperty({ required: false, description: 'Password reset token expiry date' })
@IsOptional()
@IsDate()
resetTokenExpiry?: Date | null;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
@ApiTags('users')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class UsersController {
constructor(private readonly usersService: UsersService) { }
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Create a new user' })
@ApiResponse({
status: 201,
description: 'The user has been successfully created.',
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
@ApiOperation({ summary: 'Get all users' })
@ApiResponse({
status: 200,
description: 'Return all users.',
})
findAll() {
return this.usersService.findAll();
}
@Get('inactive')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Get all inactive users' })
@ApiResponse({
status: 200,
description: 'Return all inactive users.',
})
findInactiveUsers() {
return this.usersService.findInactiveUsers();
}
@Get(':id')
@ApiOperation({ summary: 'Get a user by id' })
@ApiResponse({
status: 200,
description: 'Return the user.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Update a user' })
@ApiResponse({
status: 200,
description: 'The user has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a user' })
@ApiResponse({
status: 200,
description: 'The user has been successfully deleted.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
@Patch(':id/activate')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Activate a user' })
@ApiResponse({
status: 200,
description: 'The user has been successfully activated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' })
activateUser(
@Param('id', ParseIntPipe) id: number,
@User('role') role: Role,
) {
return this.usersService.activateUser(id, role);
}
@Patch(':id/deactivate')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Deactivate a user' })
@ApiResponse({
status: 200,
description: 'The user has been successfully deactivated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' })
deactivateUser(
@Param('id', ParseIntPipe) id: number,
@User('role') role: Role,
) {
return this.usersService.deactivateUser(id, role);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule { }
\ No newline at end of file
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcrypt';
import { Role } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) { }
async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
return this.prisma.user.create({
data: {
...createUserDto,
password: hashedPassword,
isActive: false, // New users are inactive by default
},
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
}
async findAll() {
return this.prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
}
async findOne(id: number) {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: number, updateUserDto: UpdateUserDto) {
const data: any = { ...updateUserDto };
if (updateUserDto.password) {
data.password = await bcrypt.hash(updateUserDto.password, 10);
}
try {
return await this.prisma.user.update({
where: { id },
data,
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
}
async remove(id: number) {
try {
await this.prisma.user.delete({
where: { id },
});
return { message: `User with ID ${id} has been deleted` };
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
});
}
async findByResetToken(resetToken: string) {
return this.prisma.user.findFirst({
where: {
resetToken,
resetTokenExpiry: {
gt: new Date(),
},
},
});
}
async activateUser(id: number, currentUserRole: Role) {
// Only SUPERADMIN and ADMIN can activate users
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
throw new ForbiddenException('Only SUPERADMIN and ADMIN can activate users');
}
try {
return await this.prisma.user.update({
where: { id },
data: { isActive: true },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
}
async deactivateUser(id: number, currentUserRole: Role) {
// Only SUPERADMIN and ADMIN can deactivate users
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
throw new ForbiddenException('Only SUPERADMIN and ADMIN can deactivate users');
}
try {
return await this.prisma.user.update({
where: { id },
data: { isActive: false },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
}
async findInactiveUsers() {
return this.prisma.user.findMany({
where: { isActive: false },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
}
}
\ No newline at end of file
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
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!');
});
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment