├── src ├── typings │ ├── common.ts │ ├── http-request.ts │ └── ability.ts ├── core │ ├── policies │ │ ├── constants │ │ │ └── policies.constants.ts │ │ ├── decorators │ │ │ ├── policies.decorator.ts │ │ │ └── ability.decorator.ts │ │ ├── types.ts │ │ ├── factories │ │ │ └── ability.factory.ts │ │ └── guards │ │ │ └── policies.guard.ts │ ├── authentication │ │ ├── dto │ │ │ ├── user-reset-password-request.dto.ts │ │ │ ├── user-signup.dto.ts │ │ │ ├── user-reset-password.dto.ts │ │ │ └── email-code.dto.ts │ │ ├── guards │ │ │ ├── local-auth.guard.ts │ │ │ ├── google-auth.guard.ts │ │ │ └── jwt-auth.guard.ts │ │ ├── decorators │ │ │ ├── auth-public.decorator.ts │ │ │ └── user.decorator.ts │ │ ├── services │ │ │ ├── hash.service.ts │ │ │ ├── encryption.service.ts │ │ │ └── auth.service.ts │ │ ├── strategies │ │ │ ├── local.strategy.ts │ │ │ ├── jwt.strategy.ts │ │ │ └── google.strategy.ts │ │ ├── controllers │ │ │ ├── google-auth.controller.ts │ │ │ └── local-auth.controller.ts │ │ └── auth.module.ts │ ├── database │ │ ├── database.module.ts │ │ └── services │ │ │ └── prisma.service.ts │ ├── cache │ │ ├── decorators │ │ │ └── disable-cache.decorator.ts │ │ ├── interceptors │ │ │ └── disable-cache.interceptor.ts │ │ └── cache.module.ts │ ├── validation │ │ ├── validation.module.ts │ │ └── pipes │ │ │ └── validation.pipe.ts │ ├── rate-limit │ │ ├── guards │ │ │ └── rate-limit.guard.ts │ │ └── rate-limit.module.ts │ ├── body-transform │ │ ├── body-transform.module.ts │ │ └── pipes │ │ │ └── trim.pipe.ts │ ├── exceptions │ │ ├── exceptions.module.ts │ │ └── filters │ │ │ └── http-exception.filter.ts │ ├── configurations │ │ ├── configurations.module.ts │ │ ├── types.ts │ │ └── factories │ │ │ └── configurations.factory.ts │ ├── response-time │ │ ├── response-time.module.ts │ │ └── interceptors │ │ │ └── response-time.interceptor.ts │ ├── health │ │ ├── health.module.ts │ │ ├── indicators │ │ │ └── prisma.health.ts │ │ └── controllers │ │ │ └── health.controller.ts │ └── user │ │ ├── entity │ │ ├── uset-token.entity.ts │ │ └── user.entity.ts │ │ └── services │ │ └── user.service.ts ├── utils │ └── decorators │ │ ├── id.decorator.ts │ │ └── uuid.decoreator.ts ├── constants │ └── policies.actions.ts ├── models │ ├── user │ │ ├── dto │ │ │ ├── user-update-password.dto.ts │ │ │ └── user-update.dto.ts │ │ ├── guards │ │ │ └── user-policies.guard.ts │ │ ├── user.module.ts │ │ ├── policies │ │ │ └── user-ability.factory.ts │ │ └── controllers │ │ │ ├── user-verification.controllers.ts │ │ │ └── user.controller.ts │ └── post │ │ ├── services │ │ └── post.service.ts │ │ ├── post.module.ts │ │ ├── controllers │ │ └── post.controller.ts │ │ ├── guards │ │ └── post-policies.guard.ts │ │ └── policies │ │ └── post-ability.factory.ts ├── main.ts └── app.module.ts ├── nest-cli.json ├── .prettierrc ├── tsconfig.build.json ├── .env.keep ├── prisma ├── migrations │ └── migration_lock.toml └── schema.prisma ├── Dockerfile ├── .gitignore ├── .vscode └── launch.json ├── tsconfig.json ├── docker-compose.yml ├── LICENSE ├── eslint.config.mjs ├── README.MD └── package.json /src/typings/common.ts: -------------------------------------------------------------------------------- 1 | export declare type UUID = string 2 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /src/core/policies/constants/policies.constants.ts: -------------------------------------------------------------------------------- 1 | export const CHECK_POLICIES_KEY = 'check_policy' 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.env.keep: -------------------------------------------------------------------------------- 1 | POSTGRES_URL="" 2 | JWT_SECRET="" 3 | JWT_EXPIRESIN="30d" 4 | SECURITY_KEY="" 5 | GOOGLE_OAUTH_CLIENT_ID="" 6 | GOOGLE_OAUTH_CLIENT_SECRET="" -------------------------------------------------------------------------------- /src/utils/decorators/id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Param, ParseUUIDPipe } from '@nestjs/common' 2 | 3 | export const Id = () => Param('id', new ParseUUIDPipe()) 4 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/utils/decorators/uuid.decoreator.ts: -------------------------------------------------------------------------------- 1 | import { Param, ParseUUIDPipe } from '@nestjs/common' 2 | 3 | export const UUID = (name: string) => Param(name, new ParseUUIDPipe()) 4 | -------------------------------------------------------------------------------- /src/constants/policies.actions.ts: -------------------------------------------------------------------------------- 1 | export enum Action { 2 | Manage = 'manage', 3 | Create = 'create', 4 | Read = 'read', 5 | Update = 'update', 6 | Delete = 'delete', 7 | } 8 | -------------------------------------------------------------------------------- /src/core/authentication/dto/user-reset-password-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from 'class-validator' 2 | 3 | export class UserResetPasswordRequestDto { 4 | @IsString() 5 | @IsEmail() 6 | email: string 7 | } 8 | -------------------------------------------------------------------------------- /src/core/authentication/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/core/authentication/guards/google-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | 4 | @Injectable() 5 | export class GoogleAuthGuard extends AuthGuard('google') {} 6 | -------------------------------------------------------------------------------- /src/core/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PrismaService } from './services/prisma.service' 3 | 4 | @Module({ 5 | providers: [PrismaService], 6 | }) 7 | export class DatabaseModule {} 8 | -------------------------------------------------------------------------------- /src/models/user/dto/user-update-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class UserUpdatePasswordDto { 4 | @IsString() 5 | current: string 6 | 7 | @IsString() 8 | password?: string 9 | } 10 | -------------------------------------------------------------------------------- /src/core/authentication/decorators/auth-public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | export const IS_PUBLIC_KEY = 'auth-is-public' 4 | 5 | export function Public() { 6 | return SetMetadata(IS_PUBLIC_KEY, true) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/authentication/dto/user-signup.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from 'class-validator' 2 | 3 | export class UserSignupDto { 4 | @IsString() 5 | @IsEmail() 6 | email: string 7 | 8 | @IsString() 9 | password: string 10 | } 11 | -------------------------------------------------------------------------------- /src/core/cache/decorators/disable-cache.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | export const DISABLE_CACHE_KEY = 'cache-is-disabled' 4 | 5 | export function DisableCache() { 6 | return SetMetadata(DISABLE_CACHE_KEY, true) 7 | } 8 | -------------------------------------------------------------------------------- /src/typings/http-request.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { AppAbility, SubjectsAbility } from './ability' 3 | 4 | export type HttpRequest = Request & { 5 | user: UserEntity 6 | ability: AppAbility 7 | } 8 | -------------------------------------------------------------------------------- /src/core/authentication/dto/user-reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from 'class-validator' 2 | 3 | export class UserResetPasswordDto { 4 | @IsString() 5 | @IsEmail() 6 | email: string 7 | 8 | @IsString() 9 | password: string 10 | } 11 | -------------------------------------------------------------------------------- /src/models/user/dto/user-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsOptional, IsString } from 'class-validator' 2 | import { Gender } from '@prisma/client' 3 | 4 | export class UserUpdateDto { 5 | @IsString() 6 | name: string 7 | 8 | @IsEnum(Gender) 9 | @IsOptional() 10 | gender?: Gender | null 11 | } 12 | -------------------------------------------------------------------------------- /src/core/authentication/dto/email-code.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserTokenType } from '@prisma/client' 2 | import { IsEmail, IsString } from 'class-validator' 3 | 4 | export class UserResetPasswordDto { 5 | @IsString() 6 | @IsEmail() 7 | email: string 8 | 9 | @IsString() 10 | type: UserTokenType 11 | } 12 | -------------------------------------------------------------------------------- /src/core/policies/decorators/policies.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | import { CHECK_POLICIES_KEY } from '../constants/policies.constants' 3 | import { PolicyHandler } from '../types' 4 | 5 | export function CheckPolicies(...handlers: PolicyHandler[]) { 6 | return SetMetadata(CHECK_POLICIES_KEY, handlers) 7 | } 8 | -------------------------------------------------------------------------------- /src/typings/ability.ts: -------------------------------------------------------------------------------- 1 | import { User, UserToken, Post } from '@prisma/client' 2 | import { PrismaAbility, Subjects } from '@casl/prisma' 3 | 4 | export type SubjectsAbility = Subjects<{ 5 | User: User 6 | UserToken: UserToken 7 | Post: Post 8 | }> 9 | 10 | export type AppAbility = PrismaAbility<[string, T]> 11 | -------------------------------------------------------------------------------- /src/core/validation/validation.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_PIPE } from '@nestjs/core' 3 | import { ValidationPipe } from './pipes/validation.pipe' 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: APP_PIPE, 9 | useClass: ValidationPipe, 10 | }, 11 | ], 12 | }) 13 | export class ValidationModule {} 14 | -------------------------------------------------------------------------------- /src/core/rate-limit/guards/rate-limit.guard.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerGuard } from '@nestjs/throttler' 2 | import { Injectable } from '@nestjs/common' 3 | 4 | @Injectable() 5 | export class RateLimitGuard extends ThrottlerGuard { 6 | protected getTracker(req: Record): Promise { 7 | return Array.isArray(req.ips) ? req.ips[0] : req.ip 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/body-transform/body-transform.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_PIPE } from '@nestjs/core' 3 | import { BodyTrimTransformPipe } from './pipes/trim.pipe' 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: APP_PIPE, 9 | useClass: BodyTrimTransformPipe, 10 | }, 11 | ], 12 | }) 13 | export class BodyTransformModule {} 14 | -------------------------------------------------------------------------------- /src/core/exceptions/exceptions.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_FILTER } from '@nestjs/core' 3 | import { HttpExceptionFilter } from './filters/http-exception.filter' 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: APP_FILTER, 9 | useClass: HttpExceptionFilter, 10 | }, 11 | ], 12 | }) 13 | export class HttpExceptionsModule {} 14 | -------------------------------------------------------------------------------- /src/core/configurations/configurations.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ConfigModule } from '@nestjs/config' 3 | import configurations from './factories/configurations.factory' 4 | 5 | @Module({ 6 | imports: [ 7 | ConfigModule.forRoot({ 8 | load: [configurations], 9 | isGlobal: true, 10 | }), 11 | ], 12 | }) 13 | export class ConfigurationsModule {} 14 | -------------------------------------------------------------------------------- /src/core/database/services/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common' 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | constructor() { 7 | super({ 8 | log: ['query'], 9 | }) 10 | } 11 | 12 | async onModuleInit() { 13 | await this.$connect() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/core/response-time/response-time.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_INTERCEPTOR } from '@nestjs/core' 3 | import { ResponseTimeInterceptor } from './interceptors/response-time.interceptor' 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: APP_INTERCEPTOR, 9 | useClass: ResponseTimeInterceptor, 10 | }, 11 | ], 12 | }) 13 | export class ResponseTimeModule {} 14 | -------------------------------------------------------------------------------- /src/core/configurations/types.ts: -------------------------------------------------------------------------------- 1 | export interface Configurations { 2 | port: number 3 | jwt: { 4 | secret: string 5 | expiresIn: string 6 | } 7 | postgres: { 8 | url: string 9 | } 10 | security: { 11 | key: string 12 | } 13 | google: { 14 | oauth: { 15 | clientId: string 16 | clientSecret: string 17 | } 18 | } 19 | cache: { 20 | ttl: number 21 | max: number 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/models/post/services/post.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PrismaService } from 'src/core/database/services/prisma.service' 3 | 4 | @Injectable() 5 | export class PostService { 6 | constructor(private readonly prisma: PrismaService) {} 7 | 8 | public async findById(id: string) { 9 | return this.prisma.post.findUnique({ 10 | where: { 11 | id, 12 | }, 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bookworm 2 | 3 | WORKDIR /app 4 | 5 | RUN echo "deb http://deb.debian.org/debian bookworm main contrib non-free" > /etc/apt/sources.list 6 | 7 | RUN apt-get update 8 | 9 | ENV APP_HOME /app 10 | WORKDIR $APP_HOME 11 | 12 | COPY package.json . 13 | COPY package-lock.json . 14 | 15 | RUN npm install 16 | 17 | ADD . $APP_HOME 18 | RUN npm run build 19 | 20 | ENV NODE_ENV=production 21 | 22 | CMD npm run db:migration; npm run start:prod 23 | -------------------------------------------------------------------------------- /src/core/authentication/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { UserEntity } from 'src/core/user/entity/user.entity' 3 | import { HttpRequest } from 'src/typings/http-request' 4 | 5 | export const User = createParamDecorator( 6 | (data: unknown, ctx: ExecutionContext) => { 7 | const request: HttpRequest = ctx.switchToHttp().getRequest() 8 | 9 | return request.user 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /src/core/policies/decorators/ability.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { UserEntity } from 'src/core/user/entity/user.entity' 3 | import { HttpRequest } from 'src/typings/http-request' 4 | 5 | export const UserAbility = createParamDecorator( 6 | (data: unknown, ctx: ExecutionContext) => { 7 | const request: HttpRequest = ctx.switchToHttp().getRequest() 8 | 9 | return request.ability 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /src/models/post/post.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PrismaService } from 'src/core/database/services/prisma.service' 3 | import { PostController } from './controllers/post.controller' 4 | import { PostAbilityFactory } from './policies/post-ability.factory' 5 | import { PostService } from './services/post.service' 6 | 7 | @Module({ 8 | controllers: [PostController], 9 | providers: [PostService, PostAbilityFactory, PrismaService], 10 | }) 11 | export class PostModelModule {} 12 | -------------------------------------------------------------------------------- /src/core/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TerminusModule } from '@nestjs/terminus' 3 | 4 | import { PrismaService } from '../database/services/prisma.service' 5 | import { HealthController } from './controllers/health.controller' 6 | import { PrismaHealthIndicator } from './indicators/prisma.health' 7 | 8 | @Module({ 9 | imports: [TerminusModule], 10 | controllers: [HealthController], 11 | providers: [PrismaHealthIndicator, PrismaService], 12 | }) 13 | export class HealthModule {} 14 | -------------------------------------------------------------------------------- /src/core/policies/types.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from 'src/core/user/entity/user.entity' 2 | import { AppAbility } from 'src/typings/ability' 3 | import { HttpRequest } from 'src/typings/http-request' 4 | 5 | export interface IPolicyHandler { 6 | handle(ability: AppAbility, request: HttpRequest): boolean 7 | } 8 | 9 | export type PolicyHandlerCallback = ( 10 | ability: AppAbility, 11 | request: HttpRequest, 12 | ) => boolean 13 | 14 | export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback 15 | -------------------------------------------------------------------------------- /src/core/user/entity/uset-token.entity.ts: -------------------------------------------------------------------------------- 1 | import { UserTokenType } from '@prisma/client' 2 | import { IsISO8601, IsString, IsUUID } from 'class-validator' 3 | 4 | export class UserTokenEntity { 5 | @IsUUID() 6 | id: string 7 | 8 | @IsUUID() 9 | userId: string 10 | 11 | @IsString() 12 | code: string 13 | 14 | @IsString() 15 | type: UserTokenType 16 | 17 | @IsISO8601() 18 | created_at: Date | string 19 | 20 | constructor(partial?: Partial) { 21 | Object.assign(this, partial) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { VersioningType } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import compression from 'compression' 4 | 5 | import { AppModule } from './app.module' 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule) 9 | 10 | // enable versioning 11 | app.enableVersioning({ 12 | type: VersioningType.URI, 13 | defaultVersion: '1', 14 | }) 15 | 16 | app.use(compression()) 17 | 18 | await app.listen(process.env.PORT || 3000) 19 | } 20 | bootstrap() 21 | -------------------------------------------------------------------------------- /src/models/post/controllers/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common' 2 | import { DisableCache } from 'src/core/cache/decorators/disable-cache.decorator' 3 | import { UUID } from 'src/typings/common' 4 | 5 | @Controller('posts') 6 | export class PostController { 7 | @Get('/') 8 | getAllPosts() { 9 | return { 10 | posts: [], 11 | } 12 | } 13 | 14 | @Get(':id') 15 | @DisableCache() 16 | getPost(@Param('id') id: UUID) { 17 | return { 18 | id: id, 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/rate-limit/rate-limit.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_GUARD } from '@nestjs/core' 3 | import { ThrottlerModule } from '@nestjs/throttler' 4 | import { RateLimitGuard } from './guards/rate-limit.guard' 5 | 6 | @Module({ 7 | imports: [ 8 | ThrottlerModule.forRoot([ 9 | { 10 | ttl: 60, 11 | limit: 30, 12 | }, 13 | ]), 14 | ], 15 | providers: [ 16 | { 17 | provide: APP_GUARD, 18 | useClass: RateLimitGuard, 19 | }, 20 | ], 21 | }) 22 | export class RateLimitModule {} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Variables 38 | .env -------------------------------------------------------------------------------- /src/models/post/guards/post-policies.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { PoliciesGuard } from 'src/core/policies/guards/policies.guard' 4 | 5 | import { PostAbilityFactory } from '../policies/post-ability.factory' 6 | 7 | @Injectable() 8 | export class UserPoliciesGuard extends PoliciesGuard implements CanActivate { 9 | constructor( 10 | protected readonly reflector: Reflector, 11 | protected readonly postAbilityFactory: PostAbilityFactory, 12 | ) { 13 | super(reflector, postAbilityFactory) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/models/user/guards/user-policies.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { PoliciesGuard } from 'src/core/policies/guards/policies.guard' 4 | 5 | import { UserAbilityFactory } from '../policies/user-ability.factory' 6 | 7 | @Injectable() 8 | export class UserPoliciesGuard extends PoliciesGuard implements CanActivate { 9 | constructor( 10 | protected readonly reflector: Reflector, 11 | protected readonly userAbilityFactory: UserAbilityFactory, 12 | ) { 13 | super(reflector, userAbilityFactory) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Api", 8 | "args": [ 9 | "${workspaceFolder}/src/main.ts" 10 | ], 11 | "runtimeArgs": [ 12 | "--nolazy", 13 | "-r", 14 | "ts-node/register", 15 | "-r", 16 | "tsconfig-paths/register" 17 | ], 18 | "sourceMaps": true, 19 | "envFile": "${workspaceFolder}/.env", 20 | "cwd": "${workspaceRoot}", 21 | "console": "integratedTerminal", 22 | "protocol": "inspector" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /src/core/response-time/interceptors/response-time.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common' 7 | import { Observable } from 'rxjs' 8 | import { tap } from 'rxjs/operators' 9 | 10 | @Injectable() 11 | export class ResponseTimeInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const now = Date.now() 14 | 15 | return next.handle().pipe( 16 | tap(() => { 17 | const response = context.switchToHttp().getResponse() 18 | response.header('X-Response-Time', Date.now() - now) 19 | }), 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/authentication/services/hash.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | import { randomBytes } from 'crypto' 4 | import * as bcrypt from 'bcrypt' 5 | 6 | @Injectable() 7 | export class HashService { 8 | constructor(private readonly configService: ConfigService) {} 9 | 10 | public async generate(password: string) { 11 | const salt = await bcrypt.genSalt(10) 12 | 13 | return bcrypt.hash(password, salt) 14 | } 15 | 16 | public async compare(password: string, hash: string) { 17 | return bcrypt.compare(password, hash) 18 | } 19 | 20 | public async random() { 21 | return this.generate(randomBytes(12).toString('hex')) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/core/authentication/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { AuthGuard } from '@nestjs/passport' 4 | import { IS_PUBLIC_KEY } from '../decorators/auth-public.decorator' 5 | 6 | @Injectable() 7 | export class JwtAuthGuard extends AuthGuard('jwt') { 8 | constructor(private reflector: Reflector) { 9 | super() 10 | } 11 | 12 | canActivate(context: ExecutionContext) { 13 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 14 | context.getHandler(), 15 | context.getClass(), 16 | ]) 17 | 18 | if (isPublic) { 19 | return true 20 | } 21 | 22 | return super.canActivate(context) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/health/indicators/prisma.health.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { 3 | HealthCheckError, 4 | HealthIndicator, 5 | HealthIndicatorResult, 6 | } from '@nestjs/terminus' 7 | import { PrismaService } from 'src/core/database/services/prisma.service' 8 | 9 | @Injectable() 10 | export class PrismaHealthIndicator extends HealthIndicator { 11 | constructor(private readonly prismaService: PrismaService) { 12 | super() 13 | } 14 | 15 | async isHealthy(key: string): Promise { 16 | try { 17 | await this.prismaService.$queryRaw`SELECT 1` 18 | 19 | return this.getStatus(key, true) 20 | } catch (e) { 21 | throw new HealthCheckError('Prisma Check Failed', e) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/exceptions/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | } from '@nestjs/common' 7 | import { Request, Response } from 'express' 8 | 9 | @Catch(HttpException) 10 | export class HttpExceptionFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp() 13 | const response = ctx.getResponse() 14 | const request = ctx.getRequest() 15 | const status = exception.getStatus() 16 | 17 | response.status(status).send({ 18 | statusCode: status, 19 | message: exception.message, 20 | timestamp: new Date().toISOString(), 21 | path: request.url, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/models/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { UserAbilityFactory } from './policies/user-ability.factory' 3 | import { UserService } from '../../core/user/services/user.service' 4 | import { UserController } from './controllers/user.controller' 5 | import { PrismaService } from 'src/core/database/services/prisma.service' 6 | import { UserVerificationController } from './controllers/user-verification.controllers' 7 | import { AuthenticationModule } from 'src/core/authentication/auth.module' 8 | 9 | @Module({ 10 | imports: [AuthenticationModule], 11 | controllers: [UserController, UserVerificationController], 12 | providers: [UserService, UserAbilityFactory, PrismaService], 13 | exports: [UserService], 14 | }) 15 | export class UserModelModule {} 16 | -------------------------------------------------------------------------------- /src/core/cache/interceptors/disable-cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common' 2 | import { DISABLE_CACHE_KEY } from '../decorators/disable-cache.decorator' 3 | import { CacheInterceptor as NestCacheInterceptor } from '@nestjs/cache-manager' 4 | 5 | export class CacheInterceptor extends NestCacheInterceptor { 6 | protected isRequestCacheable(context: ExecutionContext): boolean { 7 | const http = context.switchToHttp() 8 | const request = http.getRequest() 9 | 10 | if (request.method !== 'GET') { 11 | return false 12 | } 13 | 14 | const isCacheDisabled: boolean = this.reflector.getAllAndOverride( 15 | DISABLE_CACHE_KEY, 16 | [context.getHandler(), context.getClass()], 17 | ) 18 | 19 | return !isCacheDisabled 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/authentication/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { Injectable, UnauthorizedException } from '@nestjs/common' 4 | import { AuthService } from '../services/auth.service' 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly authService: AuthService) { 9 | super({ 10 | usernameField: 'email', 11 | passwordField: 'password', 12 | }) 13 | } 14 | 15 | async validate(email: string, password: string): Promise { 16 | const user = this.authService.validateUser(email, password) 17 | 18 | if (!user) { 19 | throw new UnauthorizedException() 20 | } 21 | 22 | return user 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "paths": { 22 | "check-policies": [ 23 | "src/core/policies/rbac.policies.decorator" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/policies/factories/ability.factory.ts: -------------------------------------------------------------------------------- 1 | import { AbilityBuilder } from '@casl/ability' 2 | import { createPrismaAbility } from '@casl/prisma' 3 | import { Injectable } from '@nestjs/common' 4 | import { UserEntity } from 'src/core/user/entity/user.entity' 5 | import { AppAbility, SubjectsAbility } from 'src/typings/ability' 6 | 7 | @Injectable() 8 | export abstract class AbilityFactory { 9 | private abilityBuilder: AbilityBuilder> 10 | 11 | constructor() { 12 | this.abilityBuilder = new AbilityBuilder>(createPrismaAbility) 13 | } 14 | 15 | protected get AbilityBuilder() { 16 | return this.abilityBuilder 17 | } 18 | 19 | protected build() { 20 | return this.abilityBuilder.build() 21 | } 22 | 23 | public abstract defineAbilityFor(user: UserEntity): AppAbility 24 | } 25 | -------------------------------------------------------------------------------- /src/core/configurations/factories/configurations.factory.ts: -------------------------------------------------------------------------------- 1 | import { Configurations } from '../types' 2 | 3 | export default function configurations(): Configurations { 4 | return { 5 | port: parseInt(process.env.PORT, 10) || 3000, 6 | jwt: { 7 | secret: process.env.JWT_SECRET, 8 | expiresIn: process.env.JWT_EXPIRESIN || '30d', 9 | }, 10 | postgres: { 11 | url: process.env.POSTGRES_URL, 12 | }, 13 | security: { 14 | key: process.env.SECURITY_KEY, 15 | }, 16 | google: { 17 | oauth: { 18 | clientId: process.env.GOOGLE_OAUTH_CLIENT_ID, 19 | clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, 20 | }, 21 | }, 22 | cache: { 23 | ttl: parseInt(process.env.CACHE_TTL, 10) ?? 60 * 1000, 24 | max: parseInt(process.env.CACHE_MAX, 10) || 100, 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/cache/cache.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' 3 | import { ConfigModule, ConfigService } from '@nestjs/config' 4 | import { APP_INTERCEPTOR } from '@nestjs/core' 5 | import { CacheInterceptor } from './interceptors/disable-cache.interceptor' 6 | 7 | @Module({ 8 | imports: [ 9 | NestCacheModule.registerAsync({ 10 | isGlobal: true, 11 | imports: [ConfigModule], 12 | useFactory: (configService: ConfigService) => ({ 13 | ttl: configService.get('cache.ttl'), 14 | max: configService.get('cache.max'), 15 | }), 16 | inject: [ConfigService], 17 | }), 18 | ], 19 | providers: [ 20 | { 21 | provide: APP_INTERCEPTOR, 22 | useClass: CacheInterceptor, 23 | }, 24 | ], 25 | }) 26 | export class CacheModule {} 27 | -------------------------------------------------------------------------------- /src/core/body-transform/pipes/trim.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class BodyTrimTransformPipe implements PipeTransform { 5 | async transform(values: any, metadata: ArgumentMetadata) { 6 | if (metadata.type === 'body' && typeof values === 'object') { 7 | return Object.entries(values).reduce((acc, [key, value]) => { 8 | return { 9 | ...acc, 10 | [key]: this.normalizeField(key, value), 11 | } 12 | }, {}) 13 | } 14 | 15 | return values 16 | } 17 | 18 | private normalizeField(key: string, value: unknown) { 19 | if (key === 'password' || typeof value !== 'string') { 20 | return value 21 | } 22 | 23 | if (key === 'email') { 24 | return value.trim().toLowerCase() 25 | } 26 | 27 | return value.trim() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/models/user/policies/user-ability.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { Action } from 'src/constants/policies.actions' 4 | import { AbilityFactory } from 'src/core/policies/factories/ability.factory' 5 | import { SubjectsAbility } from 'src/typings/ability' 6 | import { UserEntity } from '../../../core/user/entity/user.entity' 7 | 8 | @Injectable() 9 | export class UserAbilityFactory extends AbilityFactory { 10 | defineAbilityFor(user: UserEntity) { 11 | const { can, cannot } = this.AbilityBuilder 12 | 13 | if (user.isAdmin) { 14 | can(Action.Manage, 'User', 'all') 15 | } 16 | 17 | can(Action.Read, 'User', { 18 | id: user.id, 19 | }) 20 | 21 | can(Action.Update, 'User', { 22 | id: user.id, 23 | }) 24 | 25 | cannot(Action.Delete, 'User', { 26 | id: user.id, 27 | }) 28 | 29 | return this.build() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/core/authentication/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { Injectable } from '@nestjs/common' 4 | import { ConfigService } from '@nestjs/config' 5 | import { UserEntity } from 'src/core/user/entity/user.entity' 6 | import { AuthService } from '../services/auth.service' 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | protected readonly configService: ConfigService, 12 | private readonly authService: AuthService, 13 | ) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: configService.get('jwt.secret'), 18 | }) 19 | } 20 | 21 | async validate(payload: { sub: string }) { 22 | const user = await this.authService.findUser({ 23 | id: payload.sub, 24 | }) 25 | 26 | return new UserEntity(user) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Gender } from '@prisma/client' 2 | import { Exclude } from 'class-transformer' 3 | import { 4 | IsEmail, 5 | IsISO8601, 6 | IsNotEmpty, 7 | IsString, 8 | IsUUID, 9 | MaxLength, 10 | MinLength, 11 | } from 'class-validator' 12 | 13 | export class UserEntity { 14 | @IsUUID() 15 | id: string 16 | 17 | @IsString() 18 | @MaxLength(20) 19 | @MinLength(2) 20 | name: string 21 | 22 | @IsEmail() 23 | @IsNotEmpty() 24 | @MaxLength(30) 25 | email: string 26 | 27 | @IsNotEmpty() 28 | @Exclude() 29 | @MaxLength(30) 30 | @MinLength(6) 31 | password: string 32 | 33 | roles: string[] 34 | 35 | @IsString() 36 | gender: Gender 37 | 38 | @IsISO8601() 39 | created_at: Date | string 40 | 41 | @IsISO8601() 42 | updated_at: Date | string 43 | 44 | get isAdmin() { 45 | return this.roles?.includes('admin') 46 | } 47 | 48 | constructor(partial?: Partial) { 49 | Object.assign(this, partial) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models/post/policies/post-ability.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { Action } from 'src/constants/policies.actions' 4 | import { AbilityFactory } from 'src/core/policies/factories/ability.factory' 5 | import { SubjectsAbility } from 'src/typings/ability' 6 | import { UserEntity } from '../../../core/user/entity/user.entity' 7 | 8 | @Injectable() 9 | export class PostAbilityFactory extends AbilityFactory { 10 | defineAbilityFor(user: UserEntity) { 11 | const { can, cannot } = this.AbilityBuilder 12 | 13 | if (user.isAdmin) { 14 | can(Action.Manage, 'Post', 'all') 15 | } 16 | 17 | can(Action.Read, 'Post', { 18 | id: user.id, 19 | }) 20 | 21 | can(Action.Update, 'Post', { 22 | id: user.id, 23 | }) 24 | 25 | cannot(Action.Delete, 'Post', { 26 | id: user.id, 27 | }) 28 | 29 | cannot(Action.Read, 'Post', { 30 | published: false, 31 | }) 32 | 33 | return this.build() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | 4 | services: 5 | postgres: 6 | container_name: 'postgres-my-app' 7 | image: 'postgis/postgis:14-3.3-alpine' 8 | environment: 9 | POSTGRES_USER: 'postgres' 10 | POSTGRES_PASSWORD: 'changeme' 11 | POSTGRES_DB: 'my-app' 12 | PGDATA: '/data/my-app' 13 | ports: 14 | - '5432:5432' 15 | restart: 'always' 16 | 17 | server: 18 | platform: linux/x86_64 19 | container_name: 'my-app' 20 | image: my-app 21 | entrypoint: '' 22 | env_file: 23 | - .env 24 | depends_on: 25 | - postgres 26 | ports: 27 | - '4000:4000' 28 | expose: 29 | - 4000 30 | environment: 31 | - POSTGRES_URL=postgres://postgres:changeme@postgres:5432/my-app?sslmode=disable 32 | - NODE_ENV=development 33 | command: npm run start:dev 34 | volumes: 35 | - /app/node_modules 36 | - .:/app 37 | build: 38 | context: . 39 | dockerfile: ./Dockerfile 40 | restart: 'always' 41 | -------------------------------------------------------------------------------- /src/core/authentication/controllers/google-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Controller, Get, UseGuards } from '@nestjs/common' 2 | import { AuthService } from 'src/core/authentication/services/auth.service' 3 | import { DisableCache } from 'src/core/cache/decorators/disable-cache.decorator' 4 | import { UserEntity } from 'src/core/user/entity/user.entity' 5 | import { Public } from '../decorators/auth-public.decorator' 6 | import { User } from '../decorators/user.decorator' 7 | import { GoogleAuthGuard } from '../guards/google-auth.guard' 8 | 9 | @DisableCache() 10 | @Controller('auth/google') 11 | export class GoogleAuthController { 12 | constructor(private authService: AuthService) {} 13 | 14 | @Get() 15 | @Public() 16 | @UseGuards(GoogleAuthGuard) 17 | login() { 18 | /* empty */ 19 | } 20 | 21 | @Get('redirect') 22 | @Public() 23 | @UseGuards(GoogleAuthGuard) 24 | async redirect(@User() user: UserEntity) { 25 | if (!user) { 26 | throw new BadRequestException() 27 | } 28 | 29 | return this.authService.generateToken(user) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ramin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/core/health/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common' 2 | import { 3 | DiskHealthIndicator, 4 | HealthCheck, 5 | HealthCheckService, 6 | MemoryHealthIndicator, 7 | } from '@nestjs/terminus' 8 | import { Public } from 'src/core/authentication/decorators/auth-public.decorator' 9 | import { DisableCache } from 'src/core/cache/decorators/disable-cache.decorator' 10 | 11 | import { PrismaHealthIndicator } from '../indicators/prisma.health' 12 | 13 | @Controller({ 14 | path: 'health', 15 | version: VERSION_NEUTRAL, 16 | }) 17 | export class HealthController { 18 | constructor( 19 | private db: PrismaHealthIndicator, 20 | private health: HealthCheckService, 21 | private disk: DiskHealthIndicator, 22 | private memory: MemoryHealthIndicator, 23 | ) {} 24 | 25 | @Get() 26 | @HealthCheck() 27 | @Public() 28 | @DisableCache() 29 | check() { 30 | return this.health.check([ 31 | () => this.db.isHealthy('db'), 32 | () => 33 | this.disk.checkStorage('storage', { 34 | path: '/', 35 | thresholdPercent: 0.7, 36 | }), 37 | () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024), // 512MB, 38 | () => this.memory.checkRSS('memory_rss', 512 * 1024 * 1024), 39 | ]) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/validation/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipeTransform, 3 | Injectable, 4 | ArgumentMetadata, 5 | BadRequestException, 6 | HttpStatus, 7 | Type, 8 | ValidationError, 9 | } from '@nestjs/common' 10 | import { validate } from 'class-validator' 11 | import { plainToClass } from 'class-transformer' 12 | 13 | @Injectable() 14 | export class ValidationPipe implements PipeTransform { 15 | async transform(value: unknown, metadata: ArgumentMetadata) { 16 | const { metatype } = metadata 17 | 18 | if (!metatype || !this.toValidate(metatype)) { 19 | return value 20 | } 21 | 22 | if (metadata.type === 'custom') { 23 | return value 24 | } 25 | 26 | const object = plainToClass(metatype, value) 27 | const errors = await validate(object) 28 | 29 | if (errors.length > 0) { 30 | throw new BadRequestException({ 31 | statusCode: HttpStatus.BAD_REQUEST, 32 | message: JSON.stringify(this.normalizeErrors(errors)), 33 | }) 34 | } 35 | 36 | return value 37 | } 38 | 39 | private normalizeErrors(errors: ValidationError[]) { 40 | return errors.map(error => ({ 41 | property: error.property, 42 | constraints: Object.values(error.constraints), 43 | })) 44 | } 45 | 46 | private toValidate(metatype: Type): boolean { 47 | const types: Type[] = [String, Boolean, Number, Array, Object] 48 | return !types.includes(metatype) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/core/authentication/services/encryption.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | 4 | import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto' 5 | import { promisify } from 'util' 6 | 7 | @Injectable() 8 | export class EncryptionService { 9 | private readonly algorithm = 'aes-256-ctr' 10 | 11 | constructor(private readonly configService: ConfigService) {} 12 | 13 | public async encrypt(text: string) { 14 | const iv = randomBytes(16) 15 | 16 | const key = await this.getKey() 17 | const cipher = createCipheriv(this.algorithm, key, iv) 18 | 19 | const hexIv = iv.toString('hex') 20 | const hexContent = Buffer.concat([ 21 | cipher.update(text), 22 | cipher.final(), 23 | ]).toString('hex') 24 | 25 | return `${hexIv}:${hexContent}` 26 | } 27 | 28 | public async decrypt(hash: string) { 29 | const [iv, content] = hash.split(':') 30 | 31 | const key = await this.getKey() 32 | const decipher = createDecipheriv( 33 | this.algorithm, 34 | key, 35 | Buffer.from(iv, 'hex'), 36 | ) 37 | 38 | return Buffer.concat([ 39 | decipher.update(Buffer.from(content, 'hex')), 40 | decipher.final(), 41 | ]).toString() 42 | } 43 | 44 | private async getKey() { 45 | const password = this.configService.get('security.key') 46 | return (await promisify(scrypt)(password, 'salt', 32)) as Buffer 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ClassSerializerInterceptor, Module } from '@nestjs/common' 2 | import { APP_INTERCEPTOR } from '@nestjs/core' 3 | 4 | import { AuthenticationModule } from './core/authentication/auth.module' 5 | import { RateLimitModule } from './core/rate-limit/rate-limit.module' 6 | import { HttpExceptionsModule } from './core/exceptions/exceptions.module' 7 | import { ValidationModule } from './core/validation/validation.module' 8 | import { ResponseTimeModule } from './core/response-time/response-time.module' 9 | import { ConfigurationsModule } from './core/configurations/configurations.module' 10 | import { DatabaseModule } from './core/database/database.module' 11 | import { BodyTransformModule } from './core/body-transform/body-transform.module' 12 | import { HealthModule } from './core/health/health.module' 13 | import { CacheModule } from './core/cache/cache.module' 14 | 15 | import { UserModelModule } from './models/user/user.module' 16 | import { PostModelModule } from './models/post/post.module' 17 | 18 | @Module({ 19 | imports: [ 20 | // Models 21 | UserModelModule, 22 | PostModelModule, 23 | 24 | // Core Modules 25 | AuthenticationModule, 26 | DatabaseModule, 27 | RateLimitModule, 28 | HttpExceptionsModule, 29 | ValidationModule, 30 | ResponseTimeModule, 31 | ConfigurationsModule, 32 | BodyTransformModule, 33 | HealthModule, 34 | CacheModule, 35 | ], 36 | providers: [ 37 | { 38 | provide: APP_INTERCEPTOR, 39 | useClass: ClassSerializerInterceptor, 40 | }, 41 | ], 42 | }) 43 | export class AppModule {} 44 | -------------------------------------------------------------------------------- /src/models/user/controllers/user-verification.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Param, Post, Put, UseGuards } from '@nestjs/common' 2 | import { UserPoliciesGuard } from '../guards/user-policies.guard' 3 | import { UserService } from '../../../core/user/services/user.service' 4 | import { CheckPolicies } from 'src/core/policies/decorators/policies.decorator' 5 | import { Action } from 'src/constants/policies.actions' 6 | import { UserAbility } from 'src/core/policies/decorators/ability.decorator' 7 | import { AuthService } from 'src/core/authentication/services/auth.service' 8 | import { Id } from 'src/utils/decorators/id.decorator' 9 | import { AppAbility } from 'src/typings/ability' 10 | 11 | @UseGuards(UserPoliciesGuard) 12 | @Controller({ 13 | path: 'users/verify', 14 | }) 15 | export class UserVerificationController { 16 | constructor( 17 | private readonly userService: UserService, 18 | private readonly authService: AuthService, 19 | ) {} 20 | 21 | @Post('email/:id') 22 | @CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'User')) 23 | async verifyEmailRequest( 24 | @UserAbility() ability: AppAbility, 25 | @Id() id: string, 26 | ) { 27 | await this.authService.sendEmailVerificationCode(ability, id) 28 | } 29 | 30 | @Put('email/:id/:token') 31 | @CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'User')) 32 | async verifyEmail( 33 | @UserAbility() ability: AppAbility, 34 | @Id() id: string, 35 | @Param('token') token: string, 36 | ) { 37 | await this.authService.verifyEmail(ability, id, token) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/policies/guards/policies.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { UserEntity } from 'src/core/user/entity/user.entity' 4 | import { HttpRequest } from 'src/typings/http-request' 5 | 6 | import { AbilityFactory } from '../factories/ability.factory' 7 | import { CHECK_POLICIES_KEY } from '../constants/policies.constants' 8 | 9 | import type { SubjectsAbility } from 'src/typings/ability' 10 | import type { PolicyHandler } from '../types' 11 | 12 | @Injectable() 13 | export class PoliciesGuard implements CanActivate { 14 | constructor( 15 | protected readonly reflector: Reflector, 16 | protected readonly ability: AbilityFactory, 17 | ) {} 18 | 19 | async canActivate(context: ExecutionContext): Promise { 20 | const policyHandlers = 21 | this.reflector.getAllAndOverride(CHECK_POLICIES_KEY, [ 22 | context.getHandler(), 23 | context.getClass(), 24 | ]) || [] 25 | 26 | const request: HttpRequest = context.switchToHttp().getRequest() 27 | const { user } = request 28 | 29 | request.ability = this.ability.defineAbilityFor(user) 30 | 31 | return policyHandlers.every(handler => 32 | this.execPolicyHandler(handler, request), 33 | ) 34 | } 35 | 36 | private execPolicyHandler( 37 | handler: PolicyHandler, 38 | request: HttpRequest, 39 | ) { 40 | if (typeof handler === 'function') { 41 | return handler(request.ability, request) 42 | } 43 | 44 | return handler.handle(request.ability, request) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/core/authentication/controllers/local-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common' 2 | import { LocalAuthGuard } from 'src/core/authentication/guards/local-auth.guard' 3 | import { UserEntity } from 'src/core/user/entity/user.entity' 4 | import { AuthService } from 'src/core/authentication/services/auth.service' 5 | import { Public } from '../decorators/auth-public.decorator' 6 | import { UserSignupDto } from '../dto/user-signup.dto' 7 | import { User } from '../decorators/user.decorator' 8 | import { UserResetPasswordDto } from '../dto/user-reset-password.dto' 9 | import { UserResetPasswordRequestDto } from '../dto/user-reset-password-request.dto' 10 | 11 | @Controller('auth') 12 | export class LocalAuthController { 13 | constructor(private authService: AuthService) {} 14 | 15 | @Post('login') 16 | @Public() 17 | @UseGuards(LocalAuthGuard) 18 | loginUser(@User() user: UserEntity) { 19 | return this.authService.generateToken(user) 20 | } 21 | 22 | @Post('signup') 23 | @Public() 24 | async signupUser(@Body() userSignupDto: UserSignupDto) { 25 | return this.authService.signup(userSignupDto) 26 | } 27 | 28 | @Post('password/reset') 29 | @Public() 30 | async resetPasswordRequest(@Body() { email }: UserResetPasswordRequestDto) { 31 | await this.authService.sendPasswordResetCode(email) 32 | 33 | return {} 34 | } 35 | 36 | @Post('password/reset/:token') 37 | @Public() 38 | async resetPassword( 39 | @Body() { email, password }: UserResetPasswordDto, 40 | @Param('token') token: string, 41 | ) { 42 | await this.authService.resetPassword(email, password, token) 43 | 44 | return {} 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin' 2 | import globals from 'globals' 3 | import tsParser from '@typescript-eslint/parser' 4 | import path from 'node:path' 5 | import { fileURLToPath } from 'node:url' 6 | import js from '@eslint/js' 7 | import { FlatCompat } from '@eslint/eslintrc' 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }) 16 | 17 | export default [ 18 | { 19 | ignores: ['**/.eslintrc.js'], 20 | }, 21 | ...compat.extends( 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:prettier/recommended', 24 | ), 25 | { 26 | plugins: { 27 | '@typescript-eslint': typescriptEslintEslintPlugin, 28 | }, 29 | languageOptions: { 30 | globals: { 31 | ...globals.node, 32 | ...globals.jest, 33 | }, 34 | parser: tsParser, 35 | ecmaVersion: 5, 36 | sourceType: 'module', 37 | parserOptions: { 38 | project: 'tsconfig.json', 39 | }, 40 | }, 41 | rules: { 42 | 'prettier/prettier': [ 43 | 'error', 44 | { 45 | semi: false, 46 | singleQuote: true, 47 | printWidth: 80, 48 | trailingComma: 'all', 49 | arrowParens: 'avoid', 50 | endOfLine: 'auto', 51 | }, 52 | ], 53 | '@typescript-eslint/interface-name-prefix': 'off', 54 | '@typescript-eslint/explicit-function-return-type': 'off', 55 | '@typescript-eslint/explicit-module-boundary-types': 'off', 56 | '@typescript-eslint/no-explicit-any': 'off', 57 | }, 58 | }, 59 | ] 60 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # NestJS - Prisma Boilerplate 2 | 3 | 4 | This is a fair good boilerplate to start a new NestJS project with Prisma. 5 | 6 | ## What is included 7 | 8 | - Prisma integration 9 | - Signup, Login, Verify Email and Reset Password 10 | - Google Signup and login 11 | - Authentication with Passport and JWT 12 | - Users lookup and update 13 | - Database Connections 14 | - Rate Limiting 15 | - Configurations 16 | - RBAC with Casl and Prisma 17 | - Class Serializer Interceptor 18 | - Data Validations 19 | - Exceptions Management 20 | - Health Check (Prisma, Storage, Memory) 21 | - Customizable Caching 22 | 23 | ## How to use 24 | 25 | ``` 26 | git glone https://github.com/raminious/nestjs-boilerplate/ 27 | ``` 28 | 29 | Update environment variables 30 | ``` 31 | mv .env.keep .env 32 | ``` 33 | 34 | Initialize database 35 | ``` 36 | npx prisma db push 37 | ``` 38 | 39 | Start Server 40 | ``` 41 | npm run start:dev 42 | ``` 43 | 44 | ### Environment keys 45 | 46 | | Key | Sample value | 47 | |----------------------------|----------------------------------------------------| 48 | | POSTGRES_URL | postgresql://postgres:123456@localhost:5432/dbName | 49 | | JWT_SECRET | 2a1v4y/B?TXH+MbQeThWmYq3t1l9a#C& | 50 | | JWT_EXPIRESIN | 30d | 51 | | SECURITY_KEY | C&F)J@AcQfTjWnLr4u1x!B%D*G-KaPdS | 52 | | GOOGLE_OAUTH_CLIENT_ID | *****.apps.googleusercontent.com | 53 | | GOOGLE_OAUTH_CLIENT_SECRET | GOCDPZ-R1A1P5cEEljValVoIeYcboOWc311 | 54 | | CACHE_TTL | 30000 | 55 | | CACHE_MAX | 100 | -------------------------------------------------------------------------------- /src/core/authentication/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { APP_GUARD } from '@nestjs/core' 3 | import { JwtModule } from '@nestjs/jwt' 4 | import { ConfigService } from '@nestjs/config' 5 | import { PassportModule } from '@nestjs/passport' 6 | import { ConfigurationsModule } from '../configurations/configurations.module' 7 | import { JwtAuthGuard } from './guards/jwt-auth.guard' 8 | import { AuthService } from './services/auth.service' 9 | import { JwtStrategy } from './strategies/jwt.strategy' 10 | import { LocalStrategy } from './strategies/local.strategy' 11 | import { PrismaService } from '../database/services/prisma.service' 12 | import { EncryptionService } from './services/encryption.service' 13 | import { HashService } from './services/hash.service' 14 | 15 | import { LocalAuthController } from './controllers/local-auth.controller' 16 | import { GoogleAuthController } from './controllers/google-auth.controller' 17 | import { GoogleStrategy } from './strategies/google.strategy' 18 | import { UserService } from '../user/services/user.service' 19 | 20 | @Module({ 21 | imports: [ 22 | PassportModule, 23 | JwtModule.registerAsync({ 24 | imports: [ConfigurationsModule], 25 | inject: [ConfigService], 26 | useFactory: (configService: ConfigService) => ({ 27 | secret: configService.get('jwt.secret'), 28 | signOptions: { 29 | expiresIn: configService.get('jwt.expiresIn'), 30 | }, 31 | }), 32 | }), 33 | ], 34 | providers: [ 35 | LocalStrategy, 36 | JwtStrategy, 37 | GoogleStrategy, 38 | AuthService, 39 | EncryptionService, 40 | HashService, 41 | UserService, 42 | PrismaService, 43 | { 44 | provide: APP_GUARD, 45 | useClass: JwtAuthGuard, 46 | }, 47 | ], 48 | controllers: [LocalAuthController, GoogleAuthController], 49 | exports: [AuthService], 50 | }) 51 | export class AuthenticationModule {} 52 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("POSTGRES_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 12 | name String 13 | email String @unique 14 | password String 15 | gender Gender? 16 | roles String[] 17 | origin UserOrigin @default(Local) 18 | emailVerified Boolean @default(false) @map("email_verified") 19 | createdAt DateTime @default(now()) @map("created_at") 20 | updatedAt DateTime @default(now()) @map("updated_at") 21 | tokens UserToken[] 22 | posts Post[] 23 | 24 | @@map("users") 25 | } 26 | 27 | model UserToken { 28 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 29 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 30 | userId String @map("user_id") @db.Uuid 31 | code String 32 | type UserTokenType 33 | createdAt DateTime @default(now()) @map("created_at") 34 | 35 | @@map("users-tokens") 36 | } 37 | 38 | model Post { 39 | id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid 40 | author User @relation(fields: [authorId], references: [id]) 41 | authorId String @map("author_id") @db.Uuid 42 | title String 43 | body String 44 | published Boolean @default(false) 45 | createdAt DateTime @default(now()) @map("created_at") 46 | updatedAt DateTime @default(now()) @map("updated_at") 47 | 48 | @@map("posts") 49 | } 50 | 51 | enum UserOrigin { 52 | Local 53 | Google 54 | } 55 | 56 | enum Gender { 57 | Male 58 | Female 59 | NonBinary 60 | } 61 | 62 | enum UserTokenType { 63 | EmailVerify 64 | PasswordReset 65 | } -------------------------------------------------------------------------------- /src/models/user/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | DefaultValuePipe, 5 | Get, 6 | Put, 7 | Query, 8 | UseGuards, 9 | } from '@nestjs/common' 10 | import { Action } from 'src/constants/policies.actions' 11 | import { UserAbility } from 'src/core/policies/decorators/ability.decorator' 12 | import { CheckPolicies } from 'src/core/policies/decorators/policies.decorator' 13 | import { UserPoliciesGuard } from '../guards/user-policies.guard' 14 | import { UserService } from '../../../core/user/services/user.service' 15 | import { UserUpdateDto } from '../dto/user-update.dto' 16 | import { AppAbility } from 'src/typings/ability' 17 | import { UserUpdatePasswordDto } from '../dto/user-update-password.dto' 18 | import { AuthService } from 'src/core/authentication/services/auth.service' 19 | import { Id } from 'src/utils/decorators/id.decorator' 20 | 21 | @UseGuards(UserPoliciesGuard) 22 | @Controller({ 23 | path: 'users', 24 | }) 25 | export class UserController { 26 | constructor( 27 | private readonly userService: UserService, 28 | private readonly authService: AuthService, 29 | ) {} 30 | 31 | @Get('') 32 | @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'User')) 33 | getAllUsers( 34 | @Query('limit', new DefaultValuePipe(50)) limit: number, 35 | @Query('start', new DefaultValuePipe(0)) start: number, 36 | ) { 37 | return this.userService.findAll({ 38 | limit, 39 | start, 40 | }) 41 | } 42 | 43 | @Put(':id') 44 | @CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'User')) 45 | async updateUserById( 46 | @Id() id: string, 47 | @Body() userUpdateDto: UserUpdateDto, 48 | @UserAbility() ability: AppAbility, 49 | ) { 50 | return this.userService.updateById(ability, id, userUpdateDto) 51 | } 52 | 53 | @Put(':id/password') 54 | @CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'User')) 55 | async updateUserPassword( 56 | @Id() id: string, 57 | @Body() { current, password }: UserUpdatePasswordDto, 58 | @UserAbility() ability: AppAbility, 59 | ) { 60 | await this.authService.changePassword(ability, id, current, password) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/core/user/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { subject } from '@casl/ability' 2 | import { Injectable, UnauthorizedException } from '@nestjs/common' 3 | import { Prisma } from '@prisma/client' 4 | import { Action } from 'src/constants/policies.actions' 5 | import { PrismaService } from 'src/core/database/services/prisma.service' 6 | import { UserUpdateDto } from 'src/models/user/dto/user-update.dto' 7 | import { AppAbility } from 'src/typings/ability' 8 | import type { UUID } from 'src/typings/common' 9 | import { UserEntity } from '../entity/user.entity' 10 | 11 | @Injectable() 12 | export class UserService { 13 | constructor(private readonly prisma: PrismaService) {} 14 | 15 | async checkAccess( 16 | ability: AppAbility, 17 | condition: Prisma.UserWhereUniqueInput, 18 | ) { 19 | const user = await this.prisma.user.findUnique({ 20 | where: condition, 21 | }) 22 | 23 | if (!user || ability.can(Action.Update, subject('User', user)) === false) { 24 | throw new UnauthorizedException() 25 | } 26 | 27 | return user 28 | } 29 | 30 | async findOne(id: string) { 31 | return this.prisma.user.findUnique({ 32 | where: { 33 | id, 34 | }, 35 | }) 36 | } 37 | 38 | async findAll( 39 | { limit, start } = { 40 | limit: 50, 41 | start: 0, 42 | }, 43 | ) { 44 | const [total, list] = await this.prisma.$transaction([ 45 | this.prisma.user.count(), 46 | this.prisma.user.findMany({ 47 | take: Math.min(limit, 50), 48 | skip: start, 49 | orderBy: { 50 | createdAt: 'desc', 51 | }, 52 | }), 53 | ]) 54 | 55 | return { 56 | info: { 57 | limit: Math.min(limit, total), 58 | start, 59 | total, 60 | }, 61 | data: list.map(user => new UserEntity(user)), 62 | } 63 | } 64 | 65 | async updateById(ability: AppAbility, id: UUID, dto: UserUpdateDto) { 66 | await this.checkAccess(ability, { 67 | id, 68 | }) 69 | 70 | return this.prisma.user.update({ 71 | where: { 72 | id, 73 | }, 74 | data: { 75 | name: dto.name, 76 | gender: dto.gender, 77 | }, 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/core/authentication/strategies/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport' 2 | import { ConflictException, Injectable } from '@nestjs/common' 3 | import { ConfigService } from '@nestjs/config' 4 | import { Strategy } from 'passport-google-oauth20' 5 | 6 | import { AuthService } from '../services/auth.service' 7 | import { UserEntity } from 'src/core/user/entity/user.entity' 8 | import { PrismaService } from 'src/core/database/services/prisma.service' 9 | import { HashService } from '../services/hash.service' 10 | import { UserOrigin } from '@prisma/client' 11 | 12 | interface GoogleProfile { 13 | emails: { 14 | value: string 15 | }[] 16 | name: { 17 | givenName: string 18 | } 19 | } 20 | 21 | @Injectable() 22 | export class GoogleStrategy extends PassportStrategy(Strategy) { 23 | constructor( 24 | protected readonly configService: ConfigService, 25 | private readonly authService: AuthService, 26 | private readonly hashService: HashService, 27 | private readonly prisma: PrismaService, 28 | ) { 29 | super({ 30 | clientID: configService.get('google.oauth.clientId'), 31 | clientSecret: configService.get('google.oauth.clientSecret'), 32 | callbackURL: 'http://localhost:3000/v1/auth/google/redirect', 33 | scope: ['email', 'profile'], 34 | }) 35 | } 36 | 37 | async validate( 38 | _: string, 39 | __: string, 40 | profile: GoogleProfile, 41 | ): Promise { 42 | const email = profile.emails[0].value 43 | const name = profile.name.givenName 44 | 45 | let user = await this.authService.findUser({ 46 | email, 47 | }) 48 | 49 | if (user && user.origin !== 'Google') { 50 | throw new ConflictException({ 51 | message: 'The email address is already registered in the system.', 52 | }) 53 | } 54 | 55 | if (user && user.origin === 'Google') { 56 | return new UserEntity(user) 57 | } 58 | 59 | user = await this.prisma.user.create({ 60 | data: { 61 | name, 62 | email, 63 | password: await this.hashService.random(), 64 | origin: UserOrigin.Google, 65 | emailVerified: true, 66 | gender: null, 67 | }, 68 | }) 69 | 70 | return new UserEntity(user) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json", 22 | "types:check": "tsc" 23 | }, 24 | "dependencies": { 25 | "@casl/ability": "^6.7.1", 26 | "@casl/prisma": "^1.4.1", 27 | "@nestjs/cache-manager": "^2.2.2", 28 | "@nestjs/common": "^10.4.1", 29 | "@nestjs/config": "^3.2.3", 30 | "@nestjs/core": "^10.4.1", 31 | "@nestjs/jwt": "^10.2.0", 32 | "@nestjs/passport": "^10.0.3", 33 | "@nestjs/platform-express": "^10.4.1", 34 | "@nestjs/terminus": "^10.2.3", 35 | "@nestjs/throttler": "^6.2.1", 36 | "@prisma/client": "^5.19.0", 37 | "bcrypt": "^5.1.1", 38 | "cache-manager": "^5.7.6", 39 | "class-transformer": "^0.5.1", 40 | "class-validator": "^0.14.1", 41 | "compression": "^1.7.4", 42 | "moment": "^2.30.1", 43 | "passport": "^0.7.0", 44 | "passport-google-oauth20": "^2.0.0", 45 | "passport-jwt": "^4.0.1", 46 | "passport-local": "^1.0.0", 47 | "reflect-metadata": "^0.2.2", 48 | "rimraf": "^6.0.1", 49 | "rxjs": "^7.8.1" 50 | }, 51 | "devDependencies": { 52 | "@eslint/eslintrc": "^3.1.0", 53 | "@eslint/js": "^9.9.1", 54 | "@nestjs/cli": "^10.4.5", 55 | "@nestjs/schematics": "^10.1.4", 56 | "@nestjs/testing": "^10.4.1", 57 | "@types/bcrypt": "^5.0.2", 58 | "@types/compression": "^1.7.5", 59 | "@types/express": "^4.17.21", 60 | "@types/jest": "29.5.12", 61 | "@types/moment": "^2.13.0", 62 | "@types/node": "^22.5.2", 63 | "@types/passport-google-oauth20": "^2.0.16", 64 | "@types/passport-jwt": "^4.0.1", 65 | "@types/passport-local": "^1.0.38", 66 | "@types/supertest": "^6.0.2", 67 | "@typescript-eslint/eslint-plugin": "^8.3.0", 68 | "@typescript-eslint/parser": "^8.3.0", 69 | "eslint": "^9.9.1", 70 | "eslint-config-prettier": "^9.1.0", 71 | "eslint-plugin-prettier": "^5.2.1", 72 | "globals": "^15.9.0", 73 | "jest": "^29.7.0", 74 | "prettier": "^3.3.3", 75 | "prisma": "^5.19.0", 76 | "source-map-support": "^0.5.21", 77 | "supertest": "^7.0.0", 78 | "ts-jest": "^29.2.5", 79 | "ts-loader": "^9.5.1", 80 | "ts-node": "^10.9.2", 81 | "tsconfig-paths": "^4.2.0", 82 | "typescript": "^5.5.4" 83 | }, 84 | "jest": { 85 | "moduleFileExtensions": [ 86 | "js", 87 | "json", 88 | "ts" 89 | ], 90 | "rootDir": "src", 91 | "testRegex": ".*\\.spec\\.ts$", 92 | "transform": { 93 | "^.+\\.(t|j)s$": "ts-jest" 94 | }, 95 | "collectCoverageFrom": [ 96 | "**/*.(t|j)s" 97 | ], 98 | "coverageDirectory": "../coverage", 99 | "testEnvironment": "node" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/core/authentication/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ConflictException, 4 | Injectable, 5 | NotFoundException, 6 | } from '@nestjs/common' 7 | import { JwtService } from '@nestjs/jwt' 8 | import { Prisma, User, UserOrigin, UserTokenType } from '@prisma/client' 9 | import moment from 'moment' 10 | 11 | import { PrismaService } from 'src/core/database/services/prisma.service' 12 | import { UserEntity } from 'src/core/user/entity/user.entity' 13 | import { UserService } from 'src/core/user/services/user.service' 14 | import { AppAbility } from 'src/typings/ability' 15 | import { UUID } from 'src/typings/common' 16 | import { UserSignupDto } from '../dto/user-signup.dto' 17 | import { HashService } from './hash.service' 18 | 19 | @Injectable() 20 | export class AuthService { 21 | constructor( 22 | private readonly jwtService: JwtService, 23 | private readonly hashService: HashService, 24 | private readonly userService: UserService, 25 | private readonly prisma: PrismaService, 26 | ) {} 27 | 28 | /** 29 | * 30 | * @param criteria 31 | * @returns 32 | */ 33 | public async findUser(criteria: Prisma.UserWhereInput) { 34 | return this.prisma.user.findFirst({ 35 | where: criteria, 36 | }) 37 | } 38 | 39 | /** 40 | * 41 | * @param email 42 | * @param password 43 | * @returns 44 | */ 45 | public async validateUser( 46 | email: string, 47 | password: string, 48 | ): Promise { 49 | const user = await this.findUser({ 50 | email, 51 | origin: UserOrigin.Local, 52 | }) 53 | 54 | if (!user) { 55 | throw new NotFoundException() 56 | } 57 | 58 | if ((await this.hashService.compare(password, user.password)) === false) { 59 | throw new NotFoundException() 60 | } 61 | 62 | return new UserEntity(user) 63 | } 64 | 65 | /** 66 | * 67 | * @param param0 68 | * @returns 69 | */ 70 | public async signup({ email, password }: UserSignupDto) { 71 | let user = await this.findUser({ 72 | email, 73 | origin: UserOrigin.Local, 74 | }) 75 | 76 | if (user) { 77 | throw new ConflictException({ 78 | message: 'The email address is registered in the system.', 79 | }) 80 | } 81 | 82 | user = await this.prisma.user.create({ 83 | data: { 84 | name: email.split('@')[0], 85 | email, 86 | password: await this.hashService.generate(password), 87 | gender: null, 88 | }, 89 | }) 90 | 91 | return new UserEntity(user) 92 | } 93 | 94 | /** 95 | * 96 | * @param user 97 | * @returns 98 | */ 99 | public async generateToken(user: UserEntity) { 100 | return { 101 | access_token: this.jwtService.sign({ 102 | sub: user.id, 103 | }), 104 | } 105 | } 106 | 107 | /** 108 | * 109 | * @param ability 110 | * @param current 111 | * @param password 112 | */ 113 | public async changePassword( 114 | ability: AppAbility, 115 | id: UUID, 116 | current: string, 117 | password: string, 118 | ) { 119 | const user = await this.userService.checkAccess(ability, { id }) 120 | 121 | if (current === password) { 122 | throw new BadRequestException({ 123 | message: 124 | 'The new password should not be the same as the current password', 125 | }) 126 | } 127 | 128 | if ((await this.hashService.compare(current, user.password)) === false) { 129 | throw new BadRequestException({ 130 | message: 'The current password is invalid', 131 | }) 132 | } 133 | 134 | return this.prisma.user.update({ 135 | where: { 136 | id, 137 | }, 138 | data: { 139 | password: await this.hashService.generate(password), 140 | }, 141 | }) 142 | } 143 | 144 | /** 145 | * 146 | * @returns 147 | */ 148 | public generateRandomCode() { 149 | return Math.floor(11111 + Math.random() * 99999).toString() 150 | } 151 | 152 | /** 153 | * 154 | * @param email 155 | * @param type 156 | * @returns 157 | */ 158 | public async sendEmailCode(email: User['email'], type: UserTokenType) { 159 | const user = await this.prisma.user.findFirst({ 160 | where: { email, origin: UserOrigin.Local }, 161 | include: { 162 | tokens: { 163 | where: { 164 | type, 165 | createdAt: { 166 | gt: this.getExpireTime(), 167 | }, 168 | }, 169 | }, 170 | }, 171 | }) 172 | 173 | if (!user) { 174 | throw new NotFoundException() 175 | } 176 | 177 | if (user.tokens.length > 0) { 178 | throw new ConflictException({ 179 | message: 'The code has been sent. Look for it in your inbox.', 180 | }) 181 | } 182 | 183 | const token = await this.prisma.userToken.create({ 184 | data: { 185 | userId: user.id, 186 | code: this.generateRandomCode(), 187 | type, 188 | }, 189 | }) 190 | 191 | // TODO: SEND EMAIL 192 | console.log(token) 193 | } 194 | 195 | /** 196 | * 197 | * @param email 198 | * @returns 199 | */ 200 | public async sendPasswordResetCode(email: User['email']) { 201 | return this.sendEmailCode(email, UserTokenType.PasswordReset) 202 | } 203 | 204 | /** 205 | * 206 | * @param ability 207 | * @param id 208 | * @returns 209 | */ 210 | public async sendEmailVerificationCode(ability: AppAbility, id: UUID) { 211 | const user = await this.userService.checkAccess(ability, { id }) 212 | 213 | if (user.emailVerified) { 214 | throw new BadRequestException({ 215 | message: 'Email has already been verified', 216 | }) 217 | } 218 | 219 | return this.sendEmailCode(user.email, UserTokenType.EmailVerify) 220 | } 221 | 222 | /** 223 | * 224 | * @param ability 225 | * @param id 226 | * @param code 227 | * @returns 228 | */ 229 | public async verifyEmail(ability: AppAbility, id: UUID, code: string) { 230 | await this.userService.checkAccess(ability, { id }) 231 | 232 | const token = await this.prisma.userToken.findFirst({ 233 | where: { 234 | userId: id, 235 | code, 236 | createdAt: { 237 | gt: this.getExpireTime(), 238 | }, 239 | }, 240 | }) 241 | 242 | if (!token) { 243 | throw new BadRequestException() 244 | } 245 | 246 | return this.prisma.$transaction([ 247 | this.prisma.user.update({ 248 | where: { 249 | id, 250 | }, 251 | data: { 252 | emailVerified: true, 253 | }, 254 | }), 255 | this.prisma.userToken.delete({ 256 | where: { 257 | id: token.id, 258 | }, 259 | }), 260 | ]) 261 | } 262 | 263 | /** 264 | * 265 | * @param email 266 | * @param password 267 | * @param code 268 | * @returns 269 | */ 270 | public async resetPassword( 271 | email: User['email'], 272 | password: string, 273 | code: string, 274 | ) { 275 | const user = await this.prisma.user.findFirst({ 276 | where: { 277 | email, 278 | }, 279 | include: { 280 | tokens: { 281 | where: { 282 | code, 283 | createdAt: { 284 | gt: this.getExpireTime(), 285 | }, 286 | }, 287 | }, 288 | }, 289 | }) 290 | 291 | if (!user || user.tokens.length === 0) { 292 | throw new NotFoundException() 293 | } 294 | 295 | return this.prisma.$transaction([ 296 | this.prisma.user.update({ 297 | where: { 298 | id: user.id, 299 | }, 300 | data: { 301 | password: await this.hashService.generate(password), 302 | }, 303 | }), 304 | this.prisma.userToken.delete({ 305 | where: { 306 | id: user.tokens[0].id, 307 | }, 308 | }), 309 | ]) 310 | } 311 | 312 | /** 313 | * 314 | * @returns 315 | */ 316 | private getExpireTime() { 317 | return moment(Date.now()).subtract(20, 'minutes').format() 318 | } 319 | } 320 | --------------------------------------------------------------------------------