This commit is contained in:
Денис 2026-02-14 13:41:28 +03:00
commit 9e4d10c1ec
66 changed files with 10005 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
*.md
*.log

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
# Docker
PROJECT_NAME="app"
# Database
DB_PORT=5432
DB_NAME="app"
DB_USERNAME="app"
DB_PASSWORD="Strong_Password_123456"
# Redis
REDIS_PORT=6379
# Backend
BACK_PORT=4000
# How to generate: require('crypto').randomBytes(32).toString('hex')
JWT_SECRET="..."
JWT_REFRESH_SECRET="..."
# SMTP
SMTP_HOST="smtp.example.com"
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER="user@example.com"
SMTP_PASSWORD="..."
SMTP_FROM="noreply@example.com"
# Frontend URL (for password reset links etc.)
FRONTEND_URL="http://localhost:3000"

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# 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
.pnpm-store
uploads

274
.prettierignore Normal file
View File

@ -0,0 +1,274 @@
### VisualStudioCode template
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/
# *.iml
# *.ipr
# CMake
cmake-build-*/
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### Linux template
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.yarn
.pnp.*
### Vim template
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### macOS template
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### SublimeText template
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
sftp-config-alt*.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
yarn.lock
*.module.s[ac]ss.d.ts
src/shared/metronic
packages/metronic
src/shared/providers/i18n/locales-gen/compiled

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"quoteProps": "consistent",
"semi": true,
"printWidth": 120,
"tabWidth": 2
}

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:22.13-alpine
WORKDIR /app
RUN mkdir /app/node_modules
RUN chown -R node:node /app/node_modules
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@latest
COPY package.json pnpm-lock.yaml* ./
COPY tsconfig.json .
RUN \
if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
else echo "pnpm-lock.yaml not found." && exit 1; \
fi
RUN pnpm add -g @nestjs/cli
COPY . .

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# NestJS Starter
A NestJS starter template with authentication, user management, email service, and database setup.
## Features
- **Authentication**: JWT-based auth with access/refresh tokens, session management
- **User Management**: Registration, login, email verification, password reset
- **Email Service**: BullMQ queue-based email sending with Handlebars templates
- **Database**: PostgreSQL with TypeORM, migration support, transaction factory
- **API Documentation**: Swagger/OpenAPI with Dracula theme
- **Security**: Helmet, CORS, bcrypt password hashing
- **Docker**: Ready-to-use Docker Compose setup (dev & prod)
## Tech Stack
- NestJS 10
- TypeScript 5
- PostgreSQL 17 + TypeORM
- Redis + BullMQ
- JWT Authentication
- Swagger/OpenAPI
- Docker & Docker Compose
## Getting Started
### Prerequisites
- Node.js 22+
- pnpm
- Docker & Docker Compose
### Setup
1. Copy the environment file and configure it:
```bash
cp .env.example .env
```
2. Start with Docker Compose (development):
```bash
docker compose -f compose.dev.yml --env-file .env up --build
```
3. Or start locally:
```bash
pnpm install
pnpm migration:run
pnpm start:dev
```
### API Documentation
Once running, visit `http://localhost:4000/docs` for Swagger documentation.
## Project Structure
```
src/
├── auth/ # Authentication module
│ ├── dto/ # Auth DTOs (login, register, etc.)
│ ├── entities/ # Session, PasswordResetToken, VerificationCode
│ ├── guards/ # AuthGuard (JWT)
│ ├── services/ # AuthService, CustomJwtService, VerificationCodeService
│ └── utils/ # Request utilities
├── common/ # Shared utilities
│ ├── decorators/ # CurrentUser, Pagination, Filtering, Sorting decorators
│ ├── entities/ # AbstractEntity (base entity)
│ ├── enums/ # ErrorCode enum
│ ├── interceptors/ # ResponseInterceptor
│ ├── transformers/ # Decimal column transformer
│ └── utils/ # Object utils, auth utils, decimal utils
├── config/ # Environment config & validation
├── database/ # Database module, migrations, transaction factory
├── mail/ # Email service with BullMQ queue
│ ├── enums/ # Mail job names
│ └── templates/ # Handlebars email templates
├── users/ # User module
│ ├── dto/ # User DTOs
│ └── entities/ # User, Password entities
├── app.module.ts # Root module
├── data-source.ts # TypeORM CLI data source
└── main.ts # Application entry point
```
## Scripts
| Command | Description |
|---|---|
| `pnpm start:dev` | Start in development mode (watch) |
| `pnpm start:prod` | Start in production mode |
| `pnpm build` | Build the project |
| `pnpm migration:create --name=migration-name` | Create a new migration |
| `pnpm migration:generate --name=migration-name` | Auto-generate migration from entities |
| `pnpm migration:run` | Run pending migrations |
| `pnpm migration:revert` | Revert last migration |
| `pnpm lint` | Run ESLint |
| `pnpm test` | Run tests |

68
compose.dev.yml Normal file
View File

@ -0,0 +1,68 @@
services:
database:
container_name: ${PROJECT_NAME}_database
hostname: postgres
image: postgres:17.4
ports:
- 127.0.0.1:${DB_PORT}:5432
volumes:
- db_data:/var/lib/postgresql/pgdata
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/pgdata
networks:
- main_network
restart: unless-stopped
redis:
container_name: ${PROJECT_NAME}_redis
image: redis:7.4.2-alpine
ports:
- 127.0.0.1:${REDIS_PORT}:6379
networks:
- main_network
restart: unless-stopped
back:
container_name: ${PROJECT_NAME}_back
build: .
volumes:
- .:/app
- /app/node_modules
environment:
PORT: ${BACK_PORT}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
NODE_ENV: development
DB_HOST: database
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
REDIS_HOST: redis
REDIS_PORT: ${REDIS_PORT}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_SECURE: ${SMTP_SECURE}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
FRONTEND_URL: ${FRONTEND_URL}
networks:
- main_network
ports:
- 127.0.0.1:${BACK_PORT}:${BACK_PORT}
depends_on:
- database
restart: unless-stopped
command: sh -c "pnpm migration:run && pnpm start:dev"
networks:
main_network:
name: ${PROJECT_NAME}_network
driver: bridge
volumes:
db_data:
name: ${PROJECT_NAME}_volume

68
compose.yml Normal file
View File

@ -0,0 +1,68 @@
services:
database:
container_name: ${PROJECT_NAME}_database
hostname: postgres
image: postgres:17.4
ports:
- 127.0.0.1:${DB_PORT}:5432
volumes:
- db_data:/var/lib/postgresql/pgdata
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/pgdata
networks:
- main_network
restart: unless-stopped
redis:
container_name: ${PROJECT_NAME}_redis
image: redis:7.4.2-alpine
ports:
- 127.0.0.1:${REDIS_PORT}:6379
networks:
- main_network
restart: unless-stopped
back:
container_name: ${PROJECT_NAME}_back
build: .
volumes:
- .:/app
- /app/node_modules
environment:
PORT: ${BACK_PORT}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
NODE_ENV: production
DB_HOST: database
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
REDIS_HOST: redis
REDIS_PORT: ${REDIS_PORT}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_SECURE: ${SMTP_SECURE}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
FRONTEND_URL: ${FRONTEND_URL}
networks:
- main_network
ports:
- 127.0.0.1:${BACK_PORT}:${BACK_PORT}
depends_on:
- database
restart: unless-stopped
command: sh -c "pnpm migration:run && pnpm start"
networks:
main_network:
name: ${PROJECT_NAME}_network
driver: bridge
volumes:
db_data:
name: ${PROJECT_NAME}_volume

12
eslint.config.mjs Normal file
View File

@ -0,0 +1,12 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
/** @type {import('eslint').Linter.Config[]} */
export default [
{files: ["**/*.{js,mjs,cjs,ts}"]},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];

14
nest-cli.json Normal file
View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [
{
"include": "mail/templates/**/*",
"outDir": "dist"
}
]
}
}

95
package.json Normal file
View File

@ -0,0 +1,95 @@
{
"name": "nestjs-starter",
"version": "0.0.1",
"description": "NestJS starter with auth, users, mail, and database",
"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",
"typeorm": "ts-node -r tsconfig-paths/register node_modules/typeorm/cli.js",
"migration:create": "npm run typeorm migration:create src/database/migrations/$npm_config_name",
"migration:generate": "npm run typeorm migration:generate -- -p -d src/data-source.ts src/database/migrations/$npm_config_name",
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^11.1.4",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "^5.1.1",
"bullmq": "^5.49.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"handlebars": "^4.7.8",
"helmet": "^7.1.0",
"nodemailer": "^6.10.1",
"pg": "^8.13.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"swagger-themes": "^1.4.3",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^15.14.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3",
"typescript-eslint": "^8.18.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}

7262
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

21
src/app.module.ts Normal file
View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CommonModule } from './common/common.module';
import { validate } from './config/env/validate';
import { DatabaseModule } from './database/database.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({
validate,
isGlobal: true,
}),
CommonModule,
DatabaseModule,
UsersModule,
AuthModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,98 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './services/auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { RequestPasswordResetDto, ResetPasswordDto } from './dto/reset-password.dto';
import { AuthGuard } from './guards/auth.guard';
import { CurrentUser } from 'src/common/decorators/current-user.decorator';
import { User } from 'src/users/entities/user.entity';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { VerifyEmailDto } from './dto/verify-email.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { UsersService } from 'src/users/users.service';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UsersService,
) {}
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 400, description: 'Invalid input data' })
@ApiResponse({ status: 409, description: 'User with this email already exists' })
@Post('register')
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@ApiOperation({ summary: 'Log in' })
@ApiResponse({ status: 200, description: 'Successful login, returns tokens' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@Post('login')
async login(@Body() loginDto: LoginDto, @Req() req: Request) {
return this.authService.login(loginDto.email, loginDto.password, req);
}
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 200, description: 'Token refreshed successfully' })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@Post('refresh-token')
async refreshToken(@Body() { refreshToken }: RefreshTokenDto, @Req() req: Request) {
return this.authService.refreshToken(refreshToken, req);
}
@ApiOperation({ summary: 'Verify token' })
@ApiResponse({ status: 200, description: 'Token is valid' })
@ApiResponse({ status: 401, description: 'Invalid token' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Get('verify-token')
verifyToken(@CurrentUser() user: User) {
return this.userService.findOne(user.id);
}
@ApiOperation({ summary: 'Log out' })
@ApiResponse({ status: 200, description: 'Session terminated' })
@ApiBearerAuth()
@Post('logout')
@UseGuards(AuthGuard)
async logout(@Body() { refreshToken }: RefreshTokenDto) {
return this.authService.logout(refreshToken);
}
@ApiOperation({ summary: 'Request password reset' })
@ApiResponse({ status: 200, description: 'Request accepted' })
@Post('request-password-reset')
async requestPasswordReset(@Body() { email }: RequestPasswordResetDto) {
return this.authService.requestPasswordReset(email);
}
@ApiOperation({ summary: 'Reset password' })
@ApiResponse({ status: 200, description: 'Password changed successfully' })
@ApiResponse({ status: 401, description: 'Invalid token' })
@Post('reset-password')
async resetPassword(@Body() { token, newPassword }: ResetPasswordDto) {
return this.authService.resetPassword(token, newPassword);
}
@ApiOperation({ summary: 'Resend verification code' })
@ApiResponse({ status: 200, description: 'Code sent successfully' })
@ApiResponse({ status: 400, description: 'User not found or already verified' })
@Post('resend-verification')
async resendVerificationCode(@Body() { email }: ResendVerificationDto) {
return this.authService.resendVerificationCode(email);
}
@ApiOperation({ summary: 'Verify email' })
@ApiResponse({ status: 200, description: 'Email verified successfully, returns tokens' })
@ApiResponse({ status: 400, description: 'Invalid verification code or email already verified' })
@Post('verify-email')
async verifyEmail(@Body() verifyEmailDto: VerifyEmailDto, @Req() req: Request) {
return this.authService.verifyEmail(verifyEmailDto.email, verifyEmailDto.code, req);
}
}

47
src/auth/auth.module.ts Normal file
View File

@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/users/entities/user.entity';
import { Password } from 'src/users/entities/password.entity';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DbTransactionFactory } from 'src/database/transaction-factory';
import { Session } from './entities/session.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { VerificationCode } from './entities/verification-code.entity';
import { MailModule } from 'src/mail/mail.module';
import { CustomJwtService } from './services/jwt.service';
import { VerificationCodeService } from './services/verification-code.service';
import { UsersModule } from 'src/users/users.module';
@Module({
imports: [
TypeOrmModule.forFeature([
User,
Password,
Session,
PasswordResetToken,
VerificationCode,
]),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
}),
inject: [ConfigService],
}),
MailModule,
UsersModule,
],
controllers: [AuthController],
providers: [
AuthService,
ConfigService,
DbTransactionFactory,
CustomJwtService,
VerificationCodeService,
],
exports: [AuthService],
})
export class AuthModule {}

21
src/auth/dto/login.dto.ts Normal file
View File

@ -0,0 +1,21 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
description: 'User email',
example: 'user@example.com',
required: true,
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Password',
example: 'Password123!',
required: true,
})
@IsString()
@IsNotEmpty()
password: string;
}

View File

@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ description: 'Refresh token' })
@IsString()
refreshToken: string;
}

View File

@ -0,0 +1,51 @@
import { IsEmail, IsNotEmpty, MinLength, IsString, IsStrongPassword } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({
description: 'User email',
example: 'user@example.com',
required: true,
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'First name',
example: 'John',
required: true,
minLength: 3,
})
@IsString()
@IsNotEmpty()
@MinLength(3)
first_name: string;
@ApiProperty({
description: 'Last name',
example: 'Doe',
required: true,
minLength: 3,
})
@IsString()
@IsNotEmpty()
@MinLength(3)
last_name: string;
@ApiProperty({
description: 'Password',
example: 'Password123!',
required: true,
minLength: 8,
})
@IsString()
@IsStrongPassword({
minLength: 8,
minSymbols: 1,
minNumbers: 1,
minLowercase: 1,
minUppercase: 1,
})
password: string;
}

View File

@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
export class ResendVerificationDto {
@ApiProperty({ description: 'User email' })
@IsEmail()
email: string;
}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, IsStrongPassword, MinLength } from 'class-validator';
export class RequestPasswordResetDto {
@ApiProperty({ description: 'User email' })
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({ description: 'Reset token' })
@IsString()
token: string;
@ApiProperty({
description: 'New password',
example: 'Password123!',
required: true,
minLength: 8,
})
@IsString()
@MinLength(8)
@IsStrongPassword({
minLength: 8,
minSymbols: 1,
minNumbers: 1,
minLowercase: 1,
minUppercase: 1,
})
newPassword: string;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class VerifyEmailDto {
@ApiProperty({ description: 'User email' })
email: string;
@ApiProperty({ description: 'Verification code' })
code: string;
}

View File

@ -0,0 +1,25 @@
import { Entity, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { AbstractEntity } from 'src/common/entities/abstract.entity';
@Entity('password_reset_tokens')
export class PasswordResetToken extends AbstractEntity {
@Column()
token: string;
@Column()
user_id: number;
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
@Column({ type: 'timestamp' })
expires_at: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,28 @@
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { AbstractEntity } from 'src/common/entities/abstract.entity';
@Entity('sessions')
export class Session extends AbstractEntity {
@Column()
refresh_token: string;
@Column()
user_id: number;
@Column({ nullable: true })
device_info: string;
@Column({ nullable: true })
ip_address: string;
@Column({ default: true })
is_active: boolean;
@Column({ type: 'timestamp' })
expires_at: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
user: User;
}

View File

@ -0,0 +1,29 @@
import { AbstractEntity } from 'src/common/entities/abstract.entity';
import { Entity, Column } from 'typeorm';
export enum VerificationCodeTypeEnum {
Email = 'email',
}
@Entity('verification_codes')
export class VerificationCode extends AbstractEntity {
@Column()
email: string;
@Column()
code: string;
@Column({ default: true })
is_active: boolean;
@Column({
type: 'enum',
enum: VerificationCodeTypeEnum,
nullable: false,
default: VerificationCodeTypeEnum.Email,
})
type: VerificationCodeTypeEnum;
@Column({ type: 'timestamp' })
expires_at: Date;
}

View File

@ -0,0 +1,39 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ErrorCode } from 'src/common/enums/error-code.enum';
import { extractTokenFromHeader } from '../utils/request.util';
import { CustomJwtService } from '../services/jwt.service';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class AuthGuard implements CanActivate {
private readonly _logger = new Logger(AuthGuard.name);
constructor(
private jwtService: CustomJwtService,
private userService: UsersService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException(ErrorCode.InvalidToken);
}
try {
const payload = await this.jwtService.verifyAccessToken(token);
request.user = await this.userService.findOne(payload.id);
return true;
} catch (error) {
this._logger.error(error.message, error.stack);
throw new UnauthorizedException(ErrorCode.InvalidToken);
}
}
}

View File

@ -0,0 +1,274 @@
import * as bcrypt from 'bcrypt';
import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { RegisterDto } from '../dto/register.dto';
import { User } from 'src/users/entities/user.entity';
import { UsersService } from 'src/users/users.service';
import { ErrorCode } from 'src/common/enums/error-code.enum';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Session } from '../entities/session.entity';
import { PasswordResetToken } from '../entities/password-reset-token.entity';
import { VerificationCodeTypeEnum } from '../entities/verification-code.entity';
import { Request } from 'express';
import { randomBytes } from 'crypto';
import { MailService } from 'src/mail/mail.service';
import { pick } from 'src/common/utils/object.util';
import { CustomJwtService } from './jwt.service';
import { DbTransactionFactory, updateWithTransactions } from 'src/database/transaction-factory';
import { VerificationCodeService } from './verification-code.service';
type TokenPayload = Pick<User, 'id' | 'first_name' | 'last_name' | 'email'>;
@Injectable()
export class AuthService {
private readonly _logger = new Logger(AuthService.name);
private readonly MAX_ACTIVE_TOKENS: number = 5;
constructor(
@InjectRepository(Session) private readonly refreshTokenRepository: Repository<Session>,
@InjectRepository(PasswordResetToken) private readonly passwordResetTokenRepository: Repository<PasswordResetToken>,
@InjectRepository(User) private userRepository: Repository<User>,
private readonly usersService: UsersService,
private readonly jwtService: CustomJwtService,
private readonly configService: ConfigService,
private readonly mailService: MailService,
private readonly verificationCodeService: VerificationCodeService,
private readonly transactionRunner: DbTransactionFactory,
) {}
private createTokens(payload: TokenPayload) {
const accessToken = this.jwtService.createAccessToken(payload);
const refreshToken = this.jwtService.createRefreshToken(payload);
return { accessToken, refreshToken };
}
private saveRefreshToken(user: User, token: string, deviceInfo: string, ipAddress: string) {
return this.refreshTokenRepository.save({
refresh_token: token,
user_id: user.id,
device_info: deviceInfo,
ip_address: ipAddress,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
}
private async deactivateOldTokens(userId: number) {
const activeTokens = await this.refreshTokenRepository.find({
where: { user_id: userId, is_active: true },
order: { created_at: 'DESC' },
});
if (activeTokens.length >= this.MAX_ACTIVE_TOKENS) {
const tokensToDeactivate = activeTokens.slice(this.MAX_ACTIVE_TOKENS - 1);
await this.refreshTokenRepository.update(
{ id: In(tokensToDeactivate.map((t) => t.id)) },
{ is_active: false },
);
}
}
async login(email: string, password: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> {
const user = await this.usersService.findOneByEmailWithPassword(email);
const isMatch = await bcrypt.compare(password, user.password.value);
if (!isMatch) {
throw new UnauthorizedException(ErrorCode.WrongPassword);
}
if (!user.is_active) {
throw new UnauthorizedException(ErrorCode.BlockedUser);
}
if (!user.is_verified) {
throw new UnauthorizedException(ErrorCode.UserNotVerified);
}
const tokens = this.createTokens({
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
});
const deviceInfo = req.headers['user-agent'] || 'unknown';
const ipAddress = req.ip;
await this.deactivateOldTokens(user.id);
await this.saveRefreshToken(user, tokens.refreshToken, deviceInfo, ipAddress);
return tokens;
}
async refreshToken(refreshToken: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> {
try {
const payload = await this.jwtService.verifyRefreshToken(refreshToken);
const storedToken = await this.refreshTokenRepository.findOne({
where: { refresh_token: refreshToken, is_active: true },
relations: ['user'],
});
if (!storedToken) {
throw new UnauthorizedException(ErrorCode.InvalidRefreshToken);
}
const deviceInfo = req.headers['user-agent'] || 'unknown';
const ipAddress = req.ip || req.connection.remoteAddress;
if (storedToken.device_info !== deviceInfo || storedToken.ip_address !== ipAddress) {
await this.refreshTokenRepository.update(storedToken.id, { is_active: false });
throw new UnauthorizedException(ErrorCode.InvalidRefreshToken);
}
const tokens = this.createTokens({
id: payload.id,
first_name: payload.first_name,
last_name: payload.last_name,
email: payload.email,
});
await this.refreshTokenRepository.update(storedToken.id, { is_active: false });
await this.saveRefreshToken(storedToken.user, tokens.refreshToken, deviceInfo, ipAddress);
return tokens;
} catch (error) {
this._logger.error(error);
throw new UnauthorizedException(ErrorCode.InvalidRefreshToken);
}
}
async logout(refreshToken: string): Promise<void> {
await this.refreshTokenRepository.update({ refresh_token: refreshToken }, { is_active: false });
}
async register({ email, first_name, last_name, password }: RegisterDto): Promise<Pick<User, 'email' | 'is_verified'>> {
const existingUser = await this.usersService.findOneByEmail(email);
if (existingUser) {
throw new BadRequestException(ErrorCode.UserAlreadyExists);
}
const hashedPassword = await bcrypt.hash(password, 10);
const { id } = await this.usersService.create({
email,
first_name,
last_name,
password: hashedPassword,
is_verified: false,
});
const newUser = await this.usersService.findOne(id);
const code = await this.verificationCodeService.createVerificationCode(email, VerificationCodeTypeEnum.Email);
await this.mailService.sendVerificationCodeEmail(email, code);
return pick(newUser, ['email', 'is_verified']);
}
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findOneByEmail(email);
if (!user) {
return;
}
await this.passwordResetTokenRepository.update({ user_id: user.id, is_active: true }, { is_active: false });
const token = randomBytes(32).toString('hex');
const resetToken = this.passwordResetTokenRepository.create({
token,
user_id: user.id,
expires_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
});
await this.passwordResetTokenRepository.save(resetToken);
const resetUrl = `${this.configService.get('FRONTEND_URL')}/auth/reset-password?token=${token}`;
await this.mailService.sendPasswordResetEmail(user.email, resetUrl);
}
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.passwordResetTokenRepository.findOne({
where: { token, is_active: true },
relations: ['user'],
});
if (!resetToken || resetToken.expires_at < new Date()) {
throw new UnauthorizedException(ErrorCode.InvalidResetToken);
}
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(newPassword, salt);
await this.usersService.updatePassword(resetToken.user.id, hashedPassword);
await this.passwordResetTokenRepository.update(resetToken.id, { is_active: false });
}
async resendVerificationCode(email: string): Promise<void> {
const user = await this.usersService.findOneByEmail(email);
if (!user) {
throw new NotFoundException(ErrorCode.UserNotFound);
}
if (user.is_verified) {
throw new BadRequestException(ErrorCode.UserAlreadyExists);
}
const code = await this.verificationCodeService.createVerificationCode(email, VerificationCodeTypeEnum.Email);
await this.mailService.sendVerificationCodeEmail(email, code);
}
async verifyEmail(email: string, code: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> {
const transactionalRunner = await this.transactionRunner.createTransaction();
try {
await transactionalRunner.startTransaction();
const user = await this.usersService.findOneByEmail(email);
if (!user) {
throw new NotFoundException(ErrorCode.UserNotFound);
}
if (user.is_verified) {
throw new BadRequestException(ErrorCode.UserAlreadyExists);
}
await this.verificationCodeService.verifyCode(
email,
code,
VerificationCodeTypeEnum.Email,
transactionalRunner.transactionManager,
);
await updateWithTransactions.call(
this.userRepository,
{ id: user.id },
{ is_verified: true },
transactionalRunner.transactionManager,
);
const tokens = this.createTokens({
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
});
const deviceInfo = req.headers['user-agent'] || 'unknown';
const ipAddress = req.ip;
await this.deactivateOldTokens(user.id);
await this.saveRefreshToken(user, tokens.refreshToken, deviceInfo, ipAddress);
await transactionalRunner.commitTransaction();
return tokens;
} catch (error) {
await transactionalRunner.rollbackTransaction();
this._logger.error(error.message, error.stack);
throw error;
} finally {
await transactionalRunner.releaseTransaction();
}
}
}

View File

@ -0,0 +1,53 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ErrorCode } from 'src/common/enums/error-code.enum';
@Injectable()
export class CustomJwtService {
private readonly _logger = new Logger(CustomJwtService.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
createAccessToken<T>(payload: T) {
const isDevelop = this.configService.get<string>('NODE_ENV') === 'development';
return this.jwtService.sign(payload as object, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: isDevelop ? '7d' : '15m',
});
}
createRefreshToken<T>(payload: T) {
return this.jwtService.sign(payload as object, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
}
async verifyAccessToken(token: string) {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET'),
});
return payload;
} catch (error) {
this._logger.error(error);
throw new UnauthorizedException(ErrorCode.InvalidToken);
}
}
async verifyRefreshToken(token: string) {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
});
return payload;
} catch (error) {
this._logger.error(error);
throw new UnauthorizedException(ErrorCode.InvalidRefreshToken);
}
}
}

View File

@ -0,0 +1,60 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { generateVerificationCode } from "src/common/utils/auth.util";
import { VerificationCode, VerificationCodeTypeEnum } from "../entities/verification-code.entity";
import { EntityManager, MoreThan, Repository } from "typeorm";
import { ErrorCode } from 'src/common/enums/error-code.enum';
import { updateWithTransactions } from "src/database/transaction-factory";
@Injectable()
export class VerificationCodeService {
private readonly VERIFICATION_CODE_EXPIRY_MINUTES: number = 15;
constructor(
@InjectRepository(VerificationCode) private readonly verificationCodeRepository: Repository<VerificationCode>,
) {}
public async createVerificationCode(email: string, type: VerificationCodeTypeEnum) {
const code = generateVerificationCode();
await this.verificationCodeRepository.save({
email,
code,
type,
expires_at: new Date(Date.now() + this.VERIFICATION_CODE_EXPIRY_MINUTES * 60 * 1000),
});
return code;
}
public async verifyCode(
email: string,
code: string,
type: VerificationCodeTypeEnum,
transactionManager?: EntityManager,
): Promise<VerificationCode> {
const verificationCode = await this.verificationCodeRepository.findOne({
where: {
email,
code,
type,
is_active: true,
expires_at: MoreThan(new Date()),
},
});
if (!verificationCode) {
throw new BadRequestException(ErrorCode.InvalidVerificationCode);
}
await updateWithTransactions.call(
this.verificationCodeRepository,
{ id: verificationCode.id, type },
{ is_active: false },
transactionManager,
);
return verificationCode;
}
}

View File

@ -0,0 +1,9 @@
import { Request } from 'express';
export function extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type === 'Bearer') {
return token;
}
}

View File

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class CommonModule {}

View File

@ -0,0 +1,8 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
const user = req.user;
return data ? user[data] : user;
});

View File

@ -0,0 +1,104 @@
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { Between, ILike, In, IsNull, LessThan, LessThanOrEqual, MoreThan, MoreThanOrEqual, Not } from "typeorm";
import { ErrorCode } from '../enums/error-code.enum';
export type EntityFields<T> = (keyof T)[];
export interface IFiltering {
[field: string]: string;
}
interface IFilteringParams {
field: string;
rule: string;
value: string;
}
// valid filter rules
export enum FilterRule {
EQUALS = 'eq',
NOT_EQUALS = 'neq',
GREATER_THAN = 'gt',
GREATER_THAN_OR_EQUALS = 'gte',
LESS_THAN = 'lt',
LESS_THAN_OR_EQUALS = 'lte',
LIKE = 'like',
NOT_LIKE = 'nlike',
IN = 'in',
NOT_IN = 'nin',
IS_NULL = 'isnull',
IS_NOT_NULL = 'isnotnull',
BETWEEN = 'between',
}
export const getWhere = (filter: IFilteringParams) => {
if (!filter) return {};
if (filter.rule == FilterRule.IS_NULL) return IsNull();
if (filter.rule == FilterRule.IS_NOT_NULL) return Not(IsNull());
if (filter.rule == FilterRule.EQUALS) {
if (!filter.value || filter.value.trim() === '') return {};
return filter.value;
}
if (filter.rule == FilterRule.NOT_EQUALS) {
if (!filter.value || filter.value.trim() === '') return {};
return Not(filter.value);
}
if (filter.rule == FilterRule.GREATER_THAN) return MoreThan(filter.value);
if (filter.rule == FilterRule.GREATER_THAN_OR_EQUALS) return MoreThanOrEqual(filter.value);
if (filter.rule == FilterRule.LESS_THAN) return LessThan(filter.value);
if (filter.rule == FilterRule.LESS_THAN_OR_EQUALS) return LessThanOrEqual(filter.value);
if (filter.rule == FilterRule.LIKE) return ILike(`%${filter.value}%`);
if (filter.rule == FilterRule.NOT_LIKE) return Not(ILike(`%${filter.value}%`));
if (filter.rule == FilterRule.IN) {
const values = filter.value.split(',').filter(v => v.trim() !== '');
return values.length > 0 ? In(values) : {};
}
if (filter.rule == FilterRule.NOT_IN) {
const values = filter.value.split(',').filter(v => v.trim() !== '');
return values.length > 0 ? Not(In(values)) : {};
}
if (filter.rule == FilterRule.BETWEEN) return Between(...(filter.value.split(',') as [string, string]));
}
export const FilteringParams = createParamDecorator((data, ctx: ExecutionContext): IFiltering => {
const req: Request = ctx.switchToHttp().getRequest();
const queryFilters = req.query.filters as string[];
if (!queryFilters || !Array.isArray(queryFilters)) return null;
if (typeof data !== 'object') throw new BadRequestException(ErrorCode.InvalidFilterParams);
let filters: { [field: string]: any } = {};
for (const filter of queryFilters) {
const [fieldPath, rule, value] = filter.split(':');
const fieldParts = fieldPath.split('.');
const field = fieldParts.pop();
let nestedFilters = filters;
for (const part of fieldParts) {
nestedFilters[part] = nestedFilters[part] || {};
nestedFilters = nestedFilters[part];
}
if (!data.includes(fieldPath)) throw new BadRequestException(`${ErrorCode.FilterFieldNotAllowed}:${field}`);
if (!Object.values(FilterRule).includes(rule as FilterRule)) throw new BadRequestException(`${ErrorCode.InvalidFilterParams}:${rule}`);
if ((rule === FilterRule.EQUALS || rule === FilterRule.NOT_EQUALS || rule === FilterRule.IN || rule === FilterRule.NOT_IN)
&& (!value || value.trim() === '')) {
continue;
}
const whereClause = getWhere({ field, rule, value });
if (whereClause && (typeof whereClause === 'object' && Object.keys(whereClause).length === 0)) {
continue;
}
nestedFilters[field] = whereClause;
}
return filters;
});

View File

@ -0,0 +1,34 @@
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { ErrorCode } from '../enums/error-code.enum';
export interface IPagination {
limit: number;
offset: number;
page?: number; // for infinite scroll on client side
}
export type PaginatedResource<T> = {
totalCount: number;
items: T[];
page?: number; // for infinite scroll on client side
};
export const PaginationParams = createParamDecorator((data, ctx: ExecutionContext): IPagination => {
const req: Request = ctx.switchToHttp().getRequest();
const page = parseInt(req.query.page as string);
const size = parseInt(req.query.size as string);
// check if page and size are valid
if (isNaN(page) || page < 0 || isNaN(size) || size < 0) {
throw new BadRequestException(ErrorCode.InvalidPaginationParams);
}
// do not allow to fetch large slices of the dataset
if (size > 250) {
throw new BadRequestException(ErrorCode.MaximumChunkSizeExceeded);
}
const limit = size;
const offset = (page - 1) * limit;
return { limit, offset, page };
});

View File

@ -0,0 +1,3 @@
import { SetMetadata } from '@nestjs/common';
export const ResponseMessage = (message: string) => SetMetadata('response_message', message);

View File

@ -0,0 +1,30 @@
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { ErrorCode } from '../enums/error-code.enum';
type Direction = 'ASC' | 'DESC';
export interface ISorting {
[field: string]: Direction;
}
export const SortingParams = createParamDecorator((validParams, ctx: ExecutionContext): ISorting => {
const req: Request = ctx.switchToHttp().getRequest();
const sort = req.query.sort as string;
if (!sort) return null;
// check if the valid params sent is an array
if (typeof validParams !== 'object') throw new BadRequestException(ErrorCode.InvalidSortParams);
// check the format of the sort query param
const sortPattern = /^([a-zA-Z0-9_]+):(ASC|DESC|asc|desc)$/;
if (!sort.match(sortPattern)) throw new BadRequestException(ErrorCode.InvalidSortParams);
// extract the field name and direction and check if they are valid
const [field, direction] = sort.split(':');
if (!validParams.includes(field)) throw new BadRequestException(`${ErrorCode.InvalidSortParams}:${field}`);
return {
[field]: direction.toUpperCase() as Direction,
};
});

View File

@ -0,0 +1,16 @@
import {
CreateDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export abstract class AbstractEntity {
@PrimaryGeneratedColumn()
public id: number;
@CreateDateColumn()
public created_at: Date;
@UpdateDateColumn()
public updated_at: Date;
}

View File

@ -0,0 +1,21 @@
export enum ErrorCode {
// Auth errors
WrongPassword = 'wrong-password',
BlockedUser = 'blocked-user',
UserWithEmailNotFound = 'user-with-email-not-found',
UserAlreadyExists = 'user-already-exists',
UserNotFound = 'user-not-found',
InvalidRefreshToken = 'invalid-refresh-token',
InvalidResetToken = 'invalid-reset-token',
InvalidToken = 'invalid-token',
VerificationCodeSent = 'verification-code-sent',
InvalidVerificationCode = 'invalid-verification-code',
UserNotVerified = 'user-not-verified',
// Query params errors
InvalidPaginationParams = 'invalid-pagination-params',
MaximumChunkSizeExceeded = 'maximum-chunk-size-250-exceeded',
InvalidSortParams = 'invalid-sort-params',
InvalidFilterParams = 'invalid-filter-params',
FilterFieldNotAllowed = 'filter-field-not-allowed',
}

View File

@ -0,0 +1,48 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { map } from 'rxjs/operators';
export interface IResponse<T> {
statusCode: number;
message: string;
data: T;
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, IResponse<T>> {
constructor(private reflector: Reflector) { }
intercept(context: ExecutionContext, next: CallHandler): Observable<IResponse<T>> {
const httpResponse = context.switchToHttp().getResponse();
const customMessage = this.reflector.get<string>('response_message', context.getHandler());
return next.handle().pipe(
map((data: any) => {
return {
statusCode: data?.statusCode || httpResponse.statusCode || 200,
message: data?.message || customMessage || 'success',
data: this.clearResponseData(data),
};
}),
);
}
// NOTE: If we want to set custom statusCode and message in response it will set values from data to response then remove
private clearResponseData(data: any) {
if (data?.statusCode) {
data.statusCode = undefined;
}
if (data?.message) {
data.message = undefined;
}
return data;
}
}

View File

@ -0,0 +1,9 @@
export class ColumnDecimalTransformer {
to(data: number): number {
return data;
}
from(data: string): number {
return parseFloat(data);
}
}

View File

@ -0,0 +1,3 @@
export function generateVerificationCode() {
return Math.floor(100000 + Math.random() * 900000).toString();
}

View File

@ -0,0 +1,11 @@
import { ColumnOptions } from "typeorm";
import { ColumnDecimalTransformer } from "../transformers/column-decimal.transformer";
export const DECIMAL_OPTIONS: ColumnOptions = {
type: "decimal",
precision: 38,
scale: 18,
default: 0,
transformer: new ColumnDecimalTransformer(),
};

View File

@ -0,0 +1,13 @@
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce((acc, key) => {
if (key in obj) acc[key] = obj[key];
return acc;
}, {} as Pick<T, K>);
}
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key as K))
) as Omit<T, K>;
}

86
src/config/env/env.validation.ts vendored Normal file
View File

@ -0,0 +1,86 @@
import { IsEnum, IsNumber, IsString, IsStrongPassword, IsNotEmpty, IsPositive, IsBoolean } from 'class-validator';
enum NodeEnvironment {
Development = 'development',
Production = 'production',
Test = 'test',
}
export class EnvironmentVariables {
// App config
@IsEnum(NodeEnvironment)
NODE_ENV: NodeEnvironment;
@IsString()
@IsNotEmpty()
JWT_SECRET: string;
@IsString()
@IsNotEmpty()
JWT_REFRESH_SECRET: string;
@IsNumber()
@IsPositive()
PORT: number;
// Database config
@IsString()
@IsNotEmpty()
DB_HOST: string;
@IsNumber()
@IsPositive()
DB_PORT: number;
@IsString()
@IsNotEmpty()
DB_NAME: string;
@IsString()
@IsNotEmpty()
DB_USERNAME: string;
@IsString()
@IsNotEmpty()
@IsStrongPassword({
minLength: 16,
minSymbols: 0,
minNumbers: 3,
minLowercase: 4,
minUppercase: 4,
})
DB_PASSWORD: string;
// Redis config
@IsString()
@IsNotEmpty()
REDIS_HOST: string;
@IsNumber()
@IsPositive()
REDIS_PORT: number;
// SMTP config
@IsString()
@IsNotEmpty()
SMTP_HOST: string;
@IsNumber()
@IsPositive()
SMTP_PORT: number;
@IsBoolean()
SMTP_SECURE: boolean;
@IsString()
@IsNotEmpty()
SMTP_USER: string;
@IsString()
@IsNotEmpty()
SMTP_PASSWORD: string;
@IsString()
@IsNotEmpty()
FRONTEND_URL: string;
}

19
src/config/env/validate.ts vendored Normal file
View File

@ -0,0 +1,19 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { EnvironmentVariables } from './env.validation';
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

18
src/data-source.ts Normal file
View File

@ -0,0 +1,18 @@
/*
Migration classes are separate from the Nest application source code. Their lifecycle is maintained by the TypeORM CLI.
Therefore, you are not able to leverage dependency injection and other Nest specific features with migrations.
*/
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
synchronize: false,
entities: ['src/**/entities/*.entity.ts'],
migrations: ['src/database/migrations/*.ts'],
subscribers: [],
});

View File

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PasswordResetToken } from 'src/auth/entities/password-reset-token.entity';
import { Session } from 'src/auth/entities/session.entity';
import { VerificationCode } from 'src/auth/entities/verification-code.entity';
import { Password } from 'src/users/entities/password.entity';
import { User } from 'src/users/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
synchronize: false,
logging: false,
subscribers: [],
entities: [
User,
Password,
Session,
PasswordResetToken,
VerificationCode,
],
}),
}),
],
})
export class DatabaseModule {}

View File

@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { DataSource, QueryRunner, EntityManager, Repository, DeepPartial, EntityTarget, ObjectLiteral, FindOptionsWhere, DeleteResult, UpdateResult, ObjectId } from 'typeorm';
import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel';
const DEFAULT_ISOLATION_LEVEL: IsolationLevel = 'READ COMMITTED';
// TODO: Make as member of custom generic TransactionalRepository
// For now this function should be used only with binded 'this' context (bind, call or apply)
export function saveWithTransactions<Entity>(data: DeepPartial<Entity>, transactionManager: EntityManager): Promise<DeepPartial<Entity>> {
if (transactionManager) return transactionManager.save(this.target, data);
return this.save(data);
}
export function updateWithTransactions<Entity>(
criteria: string | string[] | number | number[] | Date | Date[] | ObjectId | ObjectId[] | FindOptionsWhere<Entity>,
data: DeepPartial<Entity>,
transactionManager: EntityManager,
): Promise<DeepPartial<UpdateResult>> {
if (transactionManager) return transactionManager.update(this.target, criteria, data);
return this.update(criteria, data);
}
export function deleteWithTransactions<Entity>(
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
transactionManager: EntityManager,
): Promise<DeleteResult> {
if (transactionManager) return transactionManager.delete(this.target, criteria);
return this.delete(criteria);
}
export function softDeleteWithTransactions<Entity>(
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
transactionManager: EntityManager,
): Promise<DeleteResult> {
if (transactionManager) return transactionManager.softDelete(this.target, criteria);
return this.softDelete(criteria);
}
export function restoreWithTransactions<Entity>(
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
transactionManager: EntityManager,
): Promise<DeleteResult> {
if (transactionManager) return transactionManager.restore(this.target, criteria);
return this.restore(criteria);
}
// export class TransactionalRepository<Entity extends ObjectLiteral> extends Repository<Entity> {
// constructor(target: EntityTarget<Entity>, dataSource: DataSource) {
// super(target, dataSource.createEntityManager());
// }
//
// saveWithTransactions(data: DeepPartial<Entity>, transactionManager: EntityManager): Promise<DeepPartial<Entity>> {
// if (transactionManager) return transactionManager.save(this.target, data);
// return this.save(data);
// }
// }
export abstract class ITransactionRunner {
abstract startTransaction(): Promise<void>;
abstract commitTransaction(): Promise<void>;
abstract rollbackTransaction(): Promise<void>;
abstract releaseTransaction(): Promise<void>;
}
class TransactionRunner implements ITransactionRunner {
private hasTransactionDestroyed = false;
constructor(private readonly queryRunner: QueryRunner) { }
async startTransaction(isolationLevel: IsolationLevel = DEFAULT_ISOLATION_LEVEL): Promise<void> {
if (this.queryRunner.isTransactionActive) return;
return this.queryRunner.startTransaction(isolationLevel);
}
async commitTransaction(): Promise<void> {
if (this.hasTransactionDestroyed) return;
return this.queryRunner.commitTransaction();
}
async rollbackTransaction(): Promise<void> {
if (this.hasTransactionDestroyed) return;
return this.queryRunner.rollbackTransaction();
}
async releaseTransaction(): Promise<void> {
this.hasTransactionDestroyed = true;
return this.queryRunner.release();
}
get transactionManager(): EntityManager {
return this.queryRunner.manager;
}
}
@Injectable()
export class DbTransactionFactory {
constructor(private readonly dataSource: DataSource) { }
async createTransaction(): Promise<TransactionRunner> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
return new TransactionRunner(queryRunner);
}
}

View File

@ -0,0 +1 @@
export const MAIL_QUEUE = 'email';

View File

@ -0,0 +1,4 @@
export enum MailQueueJobName {
SendPasswordResetEmail = 'send_password_reset_email',
SendVerificationCodeEmail = 'send_verification_code_email',
}

26
src/mail/mail.module.ts Normal file
View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { MailProcessor } from './mail.processor';
import { MAIL_QUEUE } from './constants/mail.constants';
@Module({
imports: [
ConfigModule,
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
},
}),
}),
BullModule.registerQueue({ name: MAIL_QUEUE }),
],
providers: [MailService, MailProcessor],
exports: [MailService],
})
export class MailModule {}

View File

@ -0,0 +1,92 @@
import { ConfigService } from '@nestjs/config';
import { createTransport } from 'nodemailer';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { MailQueueJobName } from './enums/mail-queue-job-name.enum';
import { Logger } from '@nestjs/common';
import * as handlebars from 'handlebars';
import * as fs from 'fs';
import * as path from 'path';
@Processor('email')
export class MailProcessor extends WorkerHost {
private readonly _logger = new Logger(MailProcessor.name);
private transporter;
constructor(private configService: ConfigService) {
super();
const transportConfig = {
host: this.configService.get('SMTP_HOST'),
port: this.configService.get('SMTP_PORT'),
secure: this.configService.get('SMTP_SECURE'),
auth: {
user: this.configService.get('SMTP_USER'),
pass: this.configService.get('SMTP_PASSWORD'),
},
};
this.transporter = createTransport(transportConfig);
delete transportConfig.auth.pass;
this._logger.debug('Mail transport initialized', transportConfig);
}
async process(job: Job): Promise<any> {
switch (job.name) {
case MailQueueJobName.SendPasswordResetEmail: {
const { email, resetUrl } = job.data;
await this.sendPasswordResetEmail(email, resetUrl);
break;
}
case MailQueueJobName.SendVerificationCodeEmail: {
const { email, code } = job.data;
await this.sendVerifyUserEmail(email, code);
break;
}
}
}
private async loadTemplate(templateName: string): Promise<handlebars.TemplateDelegate> {
const templatePath = path.join(process.cwd(), 'src', 'mail', 'templates', `${templateName}.hbs`);
const template = fs.readFileSync(templatePath, 'utf8');
return handlebars.compile(template);
}
private async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
try {
this._logger.debug(`Processor: ${MailQueueJobName.SendPasswordResetEmail} - ${email} - ${resetUrl}`);
const template = await this.loadTemplate('password-reset');
const html = template({ resetUrl });
const response = await this.transporter.sendMail({
from: this.configService.get('SMTP_FROM'),
to: email,
subject: 'Password Reset',
html,
});
this._logger.debug('Transport response:', response);
} catch (error) {
this._logger.error(error);
}
}
private async sendVerifyUserEmail(email: string, code: string): Promise<void> {
try {
this._logger.debug(`Processor: ${MailQueueJobName.SendVerificationCodeEmail} - ${email} - ${code}`);
const template = await this.loadTemplate('verify-user');
const html = template({ code });
const response = await this.transporter.sendMail({
from: this.configService.get('SMTP_FROM'),
to: email,
subject: 'Verification Code',
html,
});
this._logger.debug('Transport response:', response);
} catch (error) {
this._logger.error(error);
}
}
}

17
src/mail/mail.service.ts Normal file
View File

@ -0,0 +1,17 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { MailQueueJobName } from './enums/mail-queue-job-name.enum';
@Injectable()
export class MailService {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
public async sendPasswordResetEmail(email: string, resetUrl: string) {
await this.emailQueue.add(MailQueueJobName.SendPasswordResetEmail, { email, resetUrl });
}
public async sendVerificationCodeEmail(email: string, code: string) {
await this.emailQueue.add(MailQueueJobName.SendVerificationCodeEmail, { email, code });
}
}

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Password Reset</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 30px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<h2>Password Reset</h2>
<p>Hello!</p>
<p>We received a request to reset the password for your account.</p>
<p>To set a new password, click the button below:</p>
<a href="{{resetUrl}}" class="button">Reset Password</a>
<p>If you did not request a password reset, simply ignore this email.</p>
<div class="footer">
<p>This email was sent automatically, please do not reply.</p>
<p>The link is valid for 1 hour.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Email Verification</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.code-box {
display: inline-block;
padding: 15px 25px;
background-color: #f1f1f1;
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 30px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<h2>Email Verification</h2>
<p>Hello!</p>
<p>You have started the registration process.</p>
<p>Please use the following code to verify your email:</p>
<div class="code-box">{{code}}</div>
<p>If you did not register, simply ignore this email.</p>
<div class="footer">
<p>This email was sent automatically, please do not reply.</p>
<p>The verification code is valid for 1 hour.</p>
</div>
</body>
</html>

58
src/main.ts Normal file
View File

@ -0,0 +1,58 @@
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { DocumentBuilder, SwaggerCustomOptions, SwaggerModule } from '@nestjs/swagger';
import { SwaggerTheme, SwaggerThemeNameEnum } from 'swagger-themes';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const reflector = app.get(Reflector);
const configService = app.get(ConfigService);
const PORT = configService.get<number>('PORT');
const isDevelop = configService.get<string>('NODE_ENV') === 'development';
const helmetConfig = helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
imgSrc: ["'self'", "https: data: blob:"],
mediaSrc: ["'self'", "https: data: blob:"],
},
},
crossOriginResourcePolicy: {
policy: isDevelop ? 'cross-origin' : 'same-site',
},
});
const docsConfig = new DocumentBuilder()
.setTitle('API')
.setDescription('API Documentation')
.setVersion('1.0')
.addBearerAuth()
.build();
const theme = new SwaggerTheme();
const options: SwaggerCustomOptions = {
explorer: true,
customCss: theme.getBuffer(SwaggerThemeNameEnum.DRACULA),
jsonDocumentUrl: 'docs/json',
};
const document = SwaggerModule.createDocument(app, docsConfig);
SwaggerModule.setup('docs', app, document, options);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(reflector));
app.useGlobalInterceptors(new ResponseInterceptor(reflector));
app.enableCors();
app.use(helmetConfig);
await app.listen(PORT);
}
bootstrap();

View File

@ -0,0 +1,24 @@
import { IsEmail, IsNotEmpty, IsString, MinLength, IsBoolean, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(3)
first_name: string;
@IsString()
@IsOptional()
last_name?: string | null;
@IsString()
@IsOptional()
password?: string | null;
@IsBoolean()
@IsOptional()
is_verified?: boolean;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@ -0,0 +1,20 @@
import { AbstractEntity } from "src/common/entities/abstract.entity";
import { Column, Entity, OneToOne, JoinColumn } from "typeorm";
import { User } from "./user.entity";
@Entity('passwords')
export class Password extends AbstractEntity {
@Column({ length: 255 })
value: string;
@Column({ unique: true })
user_id: number;
@OneToOne(
() => User,
(user) => user.password,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
user: User;
}

View File

@ -0,0 +1,24 @@
import { AbstractEntity } from 'src/common/entities/abstract.entity';
import { Column, Entity, OneToOne } from 'typeorm';
import { Password } from './password.entity';
@Entity('users')
export class User extends AbstractEntity {
@Column({ length: 255 })
first_name: string;
@Column({ length: 255, nullable: true, default: null })
last_name?: string | null;
@Column({ length: 255, unique: true })
email: string;
@Column({ default: true })
is_active: boolean;
@Column({ default: false })
is_verified: boolean;
@OneToOne(() => Password, (password) => password.user, { cascade: true })
password: Password;
}

View File

@ -0,0 +1,36 @@
import { Controller, Get, UseGuards, Patch, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthGuard } from 'src/auth/guards/auth.guard';
import { CurrentUser } from 'src/common/decorators/current-user.decorator';
import { User } from './entities/user.entity';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { UpdateUserDto } from './dto/update-user.dto';
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile found' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(AuthGuard)
@Get('me')
getProfile(@CurrentUser() user: Partial<User>) {
return this.usersService.findOne(user.id);
}
@ApiOperation({ summary: 'Update current user profile' })
@ApiResponse({ status: 200, description: 'Profile updated successfully' })
@ApiResponse({ status: 400, description: 'Invalid data' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(AuthGuard)
@Patch('me')
updateProfile(
@CurrentUser() user: Partial<User>,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(user.id, dto);
}
}

17
src/users/users.module.ts Normal file
View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Password } from './entities/password.entity';
import { DbTransactionFactory } from 'src/database/transaction-factory';
@Module({
imports: [
TypeOrmModule.forFeature([User, Password]),
],
controllers: [UsersController],
providers: [UsersService, DbTransactionFactory],
exports: [UsersService],
})
export class UsersModule {}

147
src/users/users.service.ts Normal file
View File

@ -0,0 +1,147 @@
import * as bcrypt from 'bcrypt';
import { ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Password } from './entities/password.entity';
import { ErrorCode } from 'src/common/enums/error-code.enum';
import { DbTransactionFactory, saveWithTransactions } from 'src/database/transaction-factory';
@Injectable()
export class UsersService {
private readonly _logger = new Logger(UsersService.name);
constructor(
@InjectRepository(User) private userRepository: Repository<User>,
@InjectRepository(Password) private passwordRepository: Repository<Password>,
private transactionRunner: DbTransactionFactory,
) {}
async create({ first_name, last_name, email, password, is_verified }: CreateUserDto): Promise<User> {
const transactionalRunner = await this.transactionRunner.createTransaction();
try {
await transactionalRunner.startTransaction();
if (email) {
const existingUser = await this.findOneByEmail(email);
if (existingUser) {
throw new ConflictException(ErrorCode.UserAlreadyExists);
}
}
const newPassword = password && this.passwordRepository.create({ value: password });
const newUser = await saveWithTransactions.call(
this.userRepository,
{
first_name,
last_name,
email,
password: newPassword,
is_verified,
},
transactionalRunner.transactionManager,
);
delete newUser.password;
await transactionalRunner.commitTransaction();
return newUser;
} catch (error) {
await transactionalRunner.rollbackTransaction();
this._logger.error(error.message, error.stack);
throw error;
} finally {
await transactionalRunner.releaseTransaction();
}
}
async findOne(id: number): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: id ? id : IsNull() },
});
if (!user) {
throw new NotFoundException(ErrorCode.UserNotFound);
}
return user;
}
async findOneByEmail(email: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { email } });
return user;
}
async findOneByEmailWithPassword(email: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { email }, relations: ['password'] });
if (!user) {
throw new UnauthorizedException();
}
return user;
}
async update(id: number, { first_name, last_name, email, password }: UpdateUserDto) {
const transactionalRunner = await this.transactionRunner.createTransaction();
try {
await transactionalRunner.startTransaction();
const existingUser = await this.userRepository.findOne({
where: { id },
relations: ['password'],
});
if (!existingUser) {
throw new NotFoundException();
}
if (password) {
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt);
existingUser.password.value = hashedPassword;
}
existingUser.email = email;
existingUser.first_name = first_name;
existingUser.last_name = last_name;
const newUser = await saveWithTransactions.call(
this.userRepository,
existingUser,
transactionalRunner.transactionManager,
);
delete newUser.password;
await transactionalRunner.commitTransaction();
return newUser;
} catch (error) {
await transactionalRunner.rollbackTransaction();
this._logger.error(error.message, error.stack);
throw error;
} finally {
await transactionalRunner.releaseTransaction();
}
}
public async updatePassword(userId: number, hashedPassword: string): Promise<void> {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['password'],
});
if (!user) {
throw new NotFoundException(ErrorCode.UserNotFound);
}
user.password.value = hashedPassword;
await this.userRepository.save(user);
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}