Init
This commit is contained in:
commit
9e4d10c1ec
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
29
.env.example
Normal file
29
.env.example
Normal 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
25
.eslintrc.js
Normal 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
60
.gitignore
vendored
Normal 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
274
.prettierignore
Normal 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
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"quoteProps": "consistent",
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
101
README.md
Normal 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
68
compose.dev.yml
Normal 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
68
compose.yml
Normal 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
12
eslint.config.mjs
Normal 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
14
nest-cli.json
Normal 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
95
package.json
Normal 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
7262
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
21
src/app.module.ts
Normal file
21
src/app.module.ts
Normal 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 {}
|
||||||
98
src/auth/auth.controller.ts
Normal file
98
src/auth/auth.controller.ts
Normal 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
47
src/auth/auth.module.ts
Normal 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
21
src/auth/dto/login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
8
src/auth/dto/refresh-token.dto.ts
Normal file
8
src/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
51
src/auth/dto/register.dto.ts
Normal file
51
src/auth/dto/register.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
8
src/auth/dto/resend-verification.dto.ts
Normal file
8
src/auth/dto/resend-verification.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
31
src/auth/dto/reset-password.dto.ts
Normal file
31
src/auth/dto/reset-password.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/auth/dto/verify-email.dto.ts
Normal file
9
src/auth/dto/verify-email.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/auth/entities/password-reset-token.entity.ts
Normal file
25
src/auth/entities/password-reset-token.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
28
src/auth/entities/session.entity.ts
Normal file
28
src/auth/entities/session.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
29
src/auth/entities/verification-code.entity.ts
Normal file
29
src/auth/entities/verification-code.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
39
src/auth/guards/auth.guard.ts
Normal file
39
src/auth/guards/auth.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
274
src/auth/services/auth.service.ts
Normal file
274
src/auth/services/auth.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/auth/services/jwt.service.ts
Normal file
53
src/auth/services/jwt.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/auth/services/verification-code.service.ts
Normal file
60
src/auth/services/verification-code.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
9
src/auth/utils/request.util.ts
Normal file
9
src/auth/utils/request.util.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/common/common.module.ts
Normal file
4
src/common/common.module.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class CommonModule {}
|
||||||
8
src/common/decorators/current-user.decorator.ts
Normal file
8
src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||||
|
});
|
||||||
104
src/common/decorators/filtering-params.decorator.ts
Normal file
104
src/common/decorators/filtering-params.decorator.ts
Normal 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;
|
||||||
|
});
|
||||||
34
src/common/decorators/pagination-params.decorator.ts
Normal file
34
src/common/decorators/pagination-params.decorator.ts
Normal 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 };
|
||||||
|
});
|
||||||
3
src/common/decorators/response.decorator.ts
Normal file
3
src/common/decorators/response.decorator.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ResponseMessage = (message: string) => SetMetadata('response_message', message);
|
||||||
30
src/common/decorators/sorting-params.decorator.ts
Normal file
30
src/common/decorators/sorting-params.decorator.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
16
src/common/entities/abstract.entity.ts
Normal file
16
src/common/entities/abstract.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/common/enums/error-code.enum.ts
Normal file
21
src/common/enums/error-code.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
48
src/common/interceptors/response.interceptor.ts
Normal file
48
src/common/interceptors/response.interceptor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/transformers/column-decimal.transformer.ts
Normal file
9
src/common/transformers/column-decimal.transformer.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export class ColumnDecimalTransformer {
|
||||||
|
to(data: number): number {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
from(data: string): number {
|
||||||
|
return parseFloat(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/common/utils/auth.util.ts
Normal file
3
src/common/utils/auth.util.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function generateVerificationCode() {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
}
|
||||||
11
src/common/utils/decimal.util.ts
Normal file
11
src/common/utils/decimal.util.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
|
||||||
13
src/common/utils/object.util.ts
Normal file
13
src/common/utils/object.util.ts
Normal 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
86
src/config/env/env.validation.ts
vendored
Normal 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
19
src/config/env/validate.ts
vendored
Normal 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
18
src/data-source.ts
Normal 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: [],
|
||||||
|
});
|
||||||
36
src/database/database.module.ts
Normal file
36
src/database/database.module.ts
Normal 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 {}
|
||||||
104
src/database/transaction-factory.ts
Normal file
104
src/database/transaction-factory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/mail/constants/mail.constants.ts
Normal file
1
src/mail/constants/mail.constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const MAIL_QUEUE = 'email';
|
||||||
4
src/mail/enums/mail-queue-job-name.enum.ts
Normal file
4
src/mail/enums/mail-queue-job-name.enum.ts
Normal 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
26
src/mail/mail.module.ts
Normal 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 {}
|
||||||
92
src/mail/mail.processor.ts
Normal file
92
src/mail/mail.processor.ts
Normal 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
17
src/mail/mail.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/mail/templates/password-reset.hbs
Normal file
43
src/mail/templates/password-reset.hbs
Normal 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>
|
||||||
44
src/mail/templates/verify-user.hbs
Normal file
44
src/mail/templates/verify-user.hbs
Normal 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
58
src/main.ts
Normal 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();
|
||||||
24
src/users/dto/create-user.dto.ts
Normal file
24
src/users/dto/create-user.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
src/users/dto/update-user.dto.ts
Normal file
4
src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateUserDto } from './create-user.dto';
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||||
20
src/users/entities/password.entity.ts
Normal file
20
src/users/entities/password.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
24
src/users/entities/user.entity.ts
Normal file
24
src/users/entities/user.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
36
src/users/users.controller.ts
Normal file
36
src/users/users.controller.ts
Normal 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
17
src/users/users.module.ts
Normal 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
147
src/users/users.service.ts
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user