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