├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20240622073722_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public └── images │ └── 1719485362232-167599196-icon.png ├── src ├── admins │ ├── admins.controller.spec.ts │ ├── admins.controller.ts │ ├── admins.module.ts │ ├── admins.service.spec.ts │ ├── admins.service.ts │ ├── constants │ │ └── status.enum.ts │ └── dto │ │ ├── cursor.dto.ts │ │ └── offset.dto.ts ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── constants │ │ └── role.enum.ts │ ├── decorators │ │ ├── public.decorator.ts │ │ ├── roles.decorator.ts │ │ └── user.decorator.ts │ ├── dto │ │ ├── confirm.dto.ts │ │ ├── register.dto.ts │ │ └── verify.dto.ts │ ├── guards │ │ ├── jwt-auth.guard.ts │ │ ├── local-auth.guard.ts │ │ └── roles.guard.ts │ └── strategies │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts ├── database │ ├── database.module.ts │ ├── database.service.spec.ts │ └── database.service.ts ├── file │ ├── config │ │ └── multer.config.ts │ ├── file.controller.spec.ts │ ├── file.controller.ts │ ├── file.module.ts │ ├── file.service.spec.ts │ └── file.service.ts ├── main.ts └── my-logger │ ├── my-logger.module.ts │ ├── my-logger.service.spec.ts │ └── my-logger.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | # DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" 8 | 9 | # SQLite 10 | # DATABASE_URL="file:./lucky.db" 11 | 12 | DATABASE_URL="mysql://username:password@localhost:3306/database-name" 13 | TOKEN_SECRET="something hard to guest" -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | nest-prisma-rest.code-workspace -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This is Nest JS Starter Kits 2 | 3 | This is for Backend API Development. It can be used for developing a backend as well as learning how to do. 4 | If you find it useful, give me a **GitHub star**, please. 5 | 6 | In this template, 7 | 8 | - Nest JS framework 9 | - Typescript 10 | - Database - as you like 11 | - Prisma ORM 12 | - REST api 13 | - AuthGuard by Passport & JWT 14 | - Authorization RolesGuard 15 | - bcrypt 16 | - ValidationPipe 17 | - Nest Config 18 | - Rate Limiting 19 | - Logger 20 | - file uploading 21 | - Pagination ( offset-based & cursor-based ) etc. 22 | 23 | In order to use it, 24 | 25 | **Rename** .env.example file to .env file. 26 | For **MySQL** 27 | 28 | ```bash 29 | 30 | DATABASE_URL="mysql://username:password@localhost:3306/mydb" 31 | 32 | ``` 33 | 34 | For **PostgreSQL** 35 | 36 | ```bash 37 | 38 | DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public" 39 | 40 | ``` 41 | 42 | Please note. 43 | _TOKEN_SECRET_ should be complex and hard to guess. 44 | 45 | If you use file uploading feature in this kit, 46 | create nested folders `public/images` in the root directory. 47 | But making directories is up to you. You can configure in `src/file/config/multer.config.ts`. 48 | For large projects, it is the best solution to use aws S3, DigitalOcean space, etc., 49 | instead of using file system. 50 | 51 | ## Step by Step Installation 52 | 53 | ```bash 54 | mkdir lucky 55 | cd lucky 56 | git clone https://github.com/Bonekyaw/nest-prisma-rest.git . 57 | rm -rf .git 58 | npm install 59 | npm run start:dev 60 | 61 | ``` 62 | 63 | Before you run, make sure you've renamed .env file and completed required information. 64 | 65 | I'm trying best to provide the **latest** version. But some packages may not be latest after some months. If so, you can upgrade manually one after one, or you can upgrade all at once. 66 | 67 | ```bash 68 | npm install -g npm-check-updates 69 | npm outdated 70 | ncu --upgrade 71 | npm install 72 | ``` 73 | 74 | If you find some codes not working well, please let me know your problems. 75 | 76 | ### API Endpoints 77 | 78 | List of available routes: 79 | 80 | `POST /api/v1/auth/register` - Register 81 | `POST /api/v1/auth/verify` - Verify OTP 82 | `POST /api/v1/auth/confirm` - Confirm password 83 | `POST /api/v1/auth/login` - Login 84 | `POST /api/v1/file/upload` - Uploading file or files 85 | `GET /api/v1/admins/cursor` - Get admins' list by cursor-based pagination 86 | `GET /api/v1/admins/offset` - Get admins' list by offset-based pagination 87 | 88 | #### Explanation 89 | 90 | **Auth routes**: 91 | `POST /api/v1/auth/register` - Register 92 | 93 | ```javascript 94 | Request 95 | { 96 | "phone": "0977******7" 97 | } 98 | 99 | Response 100 | { 101 | "message": "We are sending OTP to 0977******7.", 102 | "phone": "77******7", 103 | "token": "3llh4zb6rkygbrah5demt7" 104 | } 105 | ``` 106 | 107 | `POST /api/v1/auth/verify` - Verify OTP 108 | 109 | ```javascript 110 | Request 111 | { 112 | "phone": "77******7", 113 | "token": "3llh4zb6rkygbrah5demt7", 114 | "otp": "123456" 115 | } 116 | 117 | Response 118 | { 119 | "message": "Successfully OTP is verified", 120 | "phone": "77******7", 121 | "token": "xdyj8leue6ndwqoxc9lzaxl16enm0gkn" 122 | } 123 | ``` 124 | 125 | `POST /api/v1/auth/confirm` - Confirm password 126 | 127 | ```javascript 128 | Request 129 | { 130 | "token": "xdyj8leue6ndwqoxc9lzaxl16enm0gkn", 131 | "phone": "77******7", 132 | "password": "12345678" 133 | } 134 | 135 | Response 136 | { 137 | "message": "Successfully created an account.", 138 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NWIwZDhmNmUwNGJiOGMzNWY0MTlkNiIsImlhdCI6MTcxNzI0MzI4MCwiZXhwIjoxNzE3MjQ2ODgwfQ.dvJT2UsGsC1za3lhcu3b3OrMR8BCIKvSlbiIgoBoLJQ", 139 | "user_id": "1", 140 | "randomToken": "p1jlepl7t7pqcdgg1sm0crbgbodi67auj" 141 | } 142 | ``` 143 | 144 | `POST /api/v1/auth/login` - Login 145 | 146 | ```javascript 147 | Request 148 | { 149 | "phone": "0977******7", 150 | "password": "12345678" 151 | } 152 | 153 | Response 154 | { 155 | "message": "Successfully Logged In.", 156 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NTVlMDI5NzE2ZjljYTU1NTRjYTU4NCIsImlhdCI6MTcxNzQwMjQ1OSwiZXhwIjoxNzE3NDA2MDU5fQ.tZNAwjt4rM3tiZgl1LdwfYScbPqoOnMTtaKOTI1pEXY", 157 | "user_id": "1", 158 | "randomToken": "25uzndvz1lzu65fpjn9b6suaxj8gm91k" 159 | } 160 | ``` 161 | 162 | **File Upload routes**: 163 | `PUT /api/v1/file/upload` - Uploading file or files 164 | 165 | ```javascript 166 | Request with Authorization Header 167 | Body form-data Key = file 168 | ``` 169 | 170 | **Pagination routes**: 171 | `GET /api/v1/admins/cursor` - Get admins' list by cursor-based pagination 172 | `GET /api/v1/admins/offset` - Get admins' list by offset-based pagination 173 | 174 | ```javascript 175 | Request with Authorization Header 176 | Params Key = cursor, limit (OR) page, limit 177 | ``` 178 | 179 | I promise new features will come in the future if I have much time. 180 | 181 | If you have something hard to solve, 182 | DM 183 | 184 | 185 | 186 | 187 | ## Find more other Starter kits of mine ? 188 | 189 | `Nest JS for REST Api` 190 | 191 | [Nest JS + Prisma ORM - REST api](https://github.com/Bonekyaw/nest-prisma-sql-rest) - Now you are here 192 | 193 | `Nest JS for Graphql Api` 194 | 195 | [Nest JS + Prisma ORM - Graphql api](https://github.com/Bonekyaw/nest-prisma-graphql) 196 | 197 | `Node Express JS For REST Api` 198 | 199 | [Express + Prisma ORM + mongodb - rest api](https://github.com/Bonekyaw/node-express-prisma-mongodb) 200 | [Express + Prisma ORM + SQL - rest api](https://github.com/Bonekyaw/node-express-prisma-rest) 201 | [Express + mongodb - rest api](https://github.com/Bonekyaw/node-express-mongodb-rest) 202 | [Express + mongoose ODM - rest api](https://github.com/Bonekyaw/node-express-nosql-rest) 203 | [Express + sequelize ORM - rest api](https://github.com/Bonekyaw/node-express-sql-rest) 204 | 205 | `Node Express JS For Graphql Api` 206 | 207 | [Apollo server + Prisma ORM + SDL modulerized - graphql api](https://github.com/Bonekyaw/apollo-graphql-prisma) 208 | [Express + Prisma ORM + graphql js SDL modulerized - graphql api](https://github.com/Bonekyaw/node-express-graphql-prisma) 209 | [Express + Apollo server + mongoose - graphql api](https://github.com/Bonekyaw/node-express-apollo-nosql) 210 | [Express + graphql js + mongoose - graphql api](https://github.com/Bonekyaw/node-express-nosql-graphql) 211 | [Express + graphql js + sequelize ORM - graphql api](https://github.com/Bonekyaw/node-express-sql-graphql) 212 | 213 | `Mobile Application Development` 214 | 215 | [React Native Expo](https://github.com/Bonekyaw/react-native-expo) 216 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-prisma-rest", 3 | "version": "0.0.1", 4 | "description": "Nest JS + Prisma ORM - REST api", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/config": "^3.2.2", 25 | "@nestjs/core": "^10.0.0", 26 | "@nestjs/jwt": "^10.2.0", 27 | "@nestjs/mapped-types": "*", 28 | "@nestjs/passport": "^10.0.3", 29 | "@nestjs/platform-express": "^10.0.0", 30 | "@nestjs/throttler": "^5.2.0", 31 | "@prisma/client": "^5.15.1", 32 | "bcrypt": "^5.1.1", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.14.1", 35 | "compression": "^1.7.4", 36 | "moment": "^2.30.1", 37 | "multer": "^1.4.5-lts.1", 38 | "passport": "^0.7.0", 39 | "passport-jwt": "^4.0.1", 40 | "passport-local": "^1.0.0", 41 | "reflect-metadata": "^0.2.0", 42 | "rxjs": "^7.8.1" 43 | }, 44 | "devDependencies": { 45 | "@nestjs/cli": "^10.0.0", 46 | "@nestjs/schematics": "^10.0.0", 47 | "@nestjs/testing": "^10.0.0", 48 | "@types/bcrypt": "^5.0.2", 49 | "@types/express": "^4.17.17", 50 | "@types/jest": "^29.5.2", 51 | "@types/multer": "^1.4.11", 52 | "@types/node": "^20.3.1", 53 | "@types/passport-jwt": "^4.0.1", 54 | "@types/passport-local": "^1.0.38", 55 | "@types/supertest": "^6.0.0", 56 | "@typescript-eslint/eslint-plugin": "^6.0.0", 57 | "@typescript-eslint/parser": "^6.0.0", 58 | "eslint": "^8.42.0", 59 | "eslint-config-prettier": "^9.0.0", 60 | "eslint-plugin-prettier": "^5.0.0", 61 | "jest": "^29.5.0", 62 | "prettier": "^3.0.0", 63 | "prisma": "^5.15.1", 64 | "source-map-support": "^0.5.21", 65 | "supertest": "^6.3.3", 66 | "ts-jest": "^29.1.0", 67 | "ts-loader": "^9.4.3", 68 | "ts-node": "^10.9.1", 69 | "tsconfig-paths": "^4.2.0", 70 | "typescript": "^5.1.3" 71 | }, 72 | "jest": { 73 | "moduleFileExtensions": [ 74 | "js", 75 | "json", 76 | "ts" 77 | ], 78 | "rootDir": "src", 79 | "testRegex": ".*\\.spec\\.ts$", 80 | "transform": { 81 | "^.+\\.(t|j)s$": "ts-jest" 82 | }, 83 | "collectCoverageFrom": [ 84 | "**/*.(t|j)s" 85 | ], 86 | "coverageDirectory": "../coverage", 87 | "testEnvironment": "node" 88 | }, 89 | "prisma": { 90 | "seed": "ts-node prisma/seed.ts" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /prisma/migrations/20240622073722_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `Admin` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `name` VARCHAR(191) NULL, 5 | `phone` VARCHAR(191) NOT NULL, 6 | `password` VARCHAR(191) NOT NULL, 7 | `role` VARCHAR(191) NOT NULL DEFAULT 'editor', 8 | `status` VARCHAR(191) NOT NULL DEFAULT 'active', 9 | `lastLogin` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 10 | `error` INTEGER NOT NULL DEFAULT 0, 11 | `profile` VARCHAR(191) NULL, 12 | `randToken` VARCHAR(191) NOT NULL, 13 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 14 | `updatedAt` DATETIME(3) NOT NULL, 15 | 16 | UNIQUE INDEX `Admin_phone_key`(`phone`), 17 | PRIMARY KEY (`id`) 18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 19 | 20 | -- CreateTable 21 | CREATE TABLE `Otp` ( 22 | `id` INTEGER NOT NULL AUTO_INCREMENT, 23 | `phone` VARCHAR(191) NOT NULL, 24 | `otp` VARCHAR(191) NOT NULL, 25 | `rememberToken` VARCHAR(191) NOT NULL, 26 | `verifyToken` VARCHAR(191) NULL, 27 | `count` INTEGER NOT NULL, 28 | `error` INTEGER NOT NULL DEFAULT 0, 29 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 30 | `updatedAt` DATETIME(3) NOT NULL, 31 | 32 | UNIQUE INDEX `Otp_phone_key`(`phone`), 33 | PRIMARY KEY (`id`) 34 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 35 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "mysql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model Admin { 17 | id Int @id @default(autoincrement()) 18 | name String? 19 | phone String @unique 20 | password String 21 | role String @default("editor") 22 | status String @default("active") 23 | lastLogin DateTime @default(now()) 24 | error Int @default(0) 25 | profile String? 26 | randToken String 27 | createdAt DateTime @default(now()) 28 | updatedAt DateTime @updatedAt 29 | } 30 | 31 | model Otp { 32 | id Int @id @default(autoincrement()) 33 | phone String @unique 34 | otp String 35 | rememberToken String 36 | verifyToken String? 37 | count Int 38 | error Int @default(0) 39 | createdAt DateTime @default(now()) 40 | updatedAt DateTime @updatedAt 41 | } 42 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from '@prisma/client'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | const adminData: Prisma.AdminCreateInput[] = [ 7 | { 8 | name: 'Phone Nyo', 9 | phone: '09778661260', 10 | password: '', 11 | randToken: 'xirj1izi88iisvh0mt6lr9efseef45frt', 12 | }, 13 | { 14 | name: 'Ko Nay', 15 | phone: '09778661261', 16 | password: '', 17 | randToken: 'xirj1izi88iisvh0mt6lr9efseef45frt', 18 | }, 19 | { 20 | name: 'Paing Gyi', 21 | phone: '09778661262', 22 | password: '', 23 | randToken: 'xirj1izi88iisvh0mt6lr9efseef45frt', 24 | }, 25 | { 26 | name: 'Jue Jue', 27 | phone: '09778661263', 28 | password: '', 29 | randToken: 'xirj1izi88iisvh0mt6lr9efseef45frt', 30 | }, 31 | { 32 | name: 'Nant Su', 33 | phone: '09778661264', 34 | password: '', 35 | randToken: 'xirj1izi88iisvh0mt6lr9efseef45frt', 36 | }, 37 | ]; 38 | 39 | async function main() { 40 | console.log(`Start seeding ...`); 41 | const salt = await bcrypt.genSalt(10); 42 | const password = await bcrypt.hash('12345678', salt); 43 | for (const a of adminData) { 44 | a.password = password; 45 | const admin = await prisma.admin.create({ 46 | data: a, 47 | }); 48 | console.log(`Created admin with id: ${admin.id}`); 49 | } 50 | console.log(`Seeding finished.`); 51 | } 52 | 53 | main() 54 | .then(async () => { 55 | await prisma.$disconnect(); 56 | }) 57 | .catch(async (e) => { 58 | console.error(e); 59 | await prisma.$disconnect(); 60 | process.exit(1); 61 | }); 62 | -------------------------------------------------------------------------------- /public/images/1719485362232-167599196-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/nest-prisma-sql-rest/d026cca7759ad2917dacd6d2cf32814341957f5a/public/images/1719485362232-167599196-icon.png -------------------------------------------------------------------------------- /src/admins/admins.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AdminsController } from './admins.controller'; 3 | import { AdminsService } from './admins.service'; 4 | 5 | describe('AdminsController', () => { 6 | let controller: AdminsController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [AdminsController], 11 | providers: [AdminsService], 12 | }).compile(); 13 | 14 | controller = module.get(AdminsController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/admins/admins.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Query, 5 | // Post, 6 | // Body, 7 | // Patch, 8 | // Param, 9 | // Delete, 10 | } from '@nestjs/common'; 11 | 12 | import { AdminsService } from './admins.service'; 13 | import { Roles } from '../auth/decorators/roles.decorator'; 14 | import { Role } from '../auth/constants/role.enum'; 15 | import { OffsetDto } from './dto/offset.dto'; 16 | import { CursorDto } from './dto/cursor.dto'; 17 | 18 | /* 19 | * Pagination 20 | * There are two ways in pagination: 21 | * offset-based and cursor-based. Read here if you wish to know more 22 | * https://www.prisma.io/docs/orm/prisma-client/queries/pagination 23 | */ 24 | 25 | @Controller('v1/admins') 26 | @Roles(Role.Editor, Role.Admin) // Other roles can't get access 27 | export class AdminsController { 28 | constructor(private readonly adminsService: AdminsService) {} 29 | 30 | @Get('offset') 31 | async findByOffset(@Query() offsetDto: OffsetDto) { 32 | return this.adminsService.findByOffset(offsetDto); 33 | } 34 | 35 | @Get('cursor') 36 | async findByCursor(@Query() cursorDto: CursorDto) { 37 | return this.adminsService.findByCursor(cursorDto); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/admins/admins.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AdminsService } from './admins.service'; 3 | import { AdminsController } from './admins.controller'; 4 | import { DatabaseModule } from '../database/database.module'; 5 | 6 | @Module({ 7 | imports: [DatabaseModule], 8 | controllers: [AdminsController], 9 | providers: [AdminsService], 10 | exports: [AdminsService], 11 | }) 12 | export class AdminsModule {} 13 | -------------------------------------------------------------------------------- /src/admins/admins.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AdminsService } from './admins.service'; 3 | 4 | describe('AdminsService', () => { 5 | let service: AdminsService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AdminsService], 10 | }).compile(); 11 | 12 | service = module.get(AdminsService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/admins/admins.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Prisma } from '@prisma/client'; 3 | import { DatabaseService } from '../database/database.service'; 4 | import { OffsetDto } from './dto/offset.dto'; 5 | import { CursorDto } from './dto/cursor.dto'; 6 | 7 | @Injectable() 8 | export class AdminsService { 9 | constructor(private readonly prisma: DatabaseService) {} 10 | 11 | async findById(id: number) { 12 | return this.prisma.admin.findUnique({ 13 | where: { id: id }, 14 | }); 15 | } 16 | 17 | async findByPhone(phone: string) { 18 | return this.prisma.admin.findUnique({ 19 | where: { phone: phone }, 20 | }); 21 | } 22 | 23 | async create(adminData: Prisma.AdminCreateInput) { 24 | return this.prisma.admin.create({ 25 | data: adminData, 26 | }); 27 | } 28 | 29 | async update(id: number, updateAdminData: Prisma.AdminUpdateInput) { 30 | return this.prisma.admin.update({ 31 | where: { id: id }, 32 | data: updateAdminData, 33 | }); 34 | } 35 | 36 | // *** This is Offset-based Pagination *** 37 | 38 | async findByOffset(offsetDto: OffsetDto) { 39 | const page = +offsetDto.page || 1; 40 | const limit = +offsetDto.limit || 10; 41 | 42 | const offset = (page - 1) * limit; 43 | const filters = { status: 'active' }; 44 | const fields = { 45 | id: true, 46 | name: true, 47 | phone: true, 48 | role: true, 49 | status: true, 50 | lastLogin: true, 51 | profile: true, 52 | createdAt: true, 53 | }; 54 | // const relation = {}; 55 | 56 | const count = await this.prisma.admin.count({ where: filters }); 57 | const results = await this.prisma.admin.findMany({ 58 | skip: offset, 59 | take: limit, 60 | where: filters, 61 | orderBy: { createdAt: 'desc' }, 62 | select: fields, 63 | // include: relation, 64 | }); 65 | 66 | return { 67 | total: count, 68 | data: results, 69 | currentPage: page, 70 | previousPage: page == 1 ? null : page - 1, 71 | nextPage: page * limit >= count ? null : page + 1, 72 | lastPage: Math.ceil(count / limit), 73 | countPerPage: limit, 74 | }; 75 | } 76 | 77 | // Unless you need to count, here it should be like this! 78 | 79 | // const results = await this.prisma.admin.findMany({ ..., take: limit + 1, ... }); 80 | // let hasNextPage = false; 81 | // if (results.length > limit) { 82 | // hasNextPage = true; 83 | // results.pop(); 84 | // } 85 | // return { 86 | // data: results, 87 | // currentPage: page, 88 | // previousPage: page == 1 ? null : page - 1, 89 | // nextPage: hasNextPage ? page + 1 : null, 90 | // countPerPage: limit, 91 | // }; 92 | 93 | async findByCursor(cursorDto: CursorDto) { 94 | const cursor = cursorDto.cursor ? { id: +cursorDto.cursor } : null; 95 | const limit = +cursorDto.limit || 4; // Be aware of error overtaking db rows 96 | 97 | const filters = { status: 'active' }; 98 | const fields = { 99 | id: true, 100 | name: true, 101 | phone: true, 102 | role: true, 103 | status: true, 104 | lastLogin: true, 105 | profile: true, 106 | createdAt: true, 107 | }; 108 | // const relation = {}; 109 | 110 | const options = { take: limit } as any; 111 | if (cursor) { 112 | options.skip = 1; 113 | options.cursor = cursor; 114 | } 115 | options.where = filters; 116 | options.orderBy = { id: 'asc' }; 117 | options.select = fields; 118 | // if (relation) { 119 | // options.include = relation; 120 | // } 121 | 122 | const results = await this.prisma.admin.findMany(options); 123 | 124 | const lastPostInResults = results.length ? results[limit - 1] : null; // Remember: zero-based index! :) 125 | const myCursor = results.length ? lastPostInResults.id : null; 126 | 127 | return { 128 | data: results, 129 | nextCursor: myCursor, 130 | }; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/admins/constants/status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Status { 2 | Active = 'active', 3 | Freeze = 'freeze', 4 | Deactivated = 'deactivated', 5 | } 6 | -------------------------------------------------------------------------------- /src/admins/dto/cursor.dto.ts: -------------------------------------------------------------------------------- 1 | import { Matches, IsOptional } from 'class-validator'; 2 | 3 | export class CursorDto { 4 | @IsOptional() 5 | @Matches(/^[1-9]+$/, { 6 | message: 'Cursor should be integer.', 7 | }) 8 | cursor: number; 9 | 10 | @IsOptional() 11 | @Matches(/^[1-9]+$/, { 12 | message: 'Limit should be integer.', 13 | }) 14 | limit: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/admins/dto/offset.dto.ts: -------------------------------------------------------------------------------- 1 | import { Matches, IsOptional } from 'class-validator'; 2 | 3 | export class OffsetDto { 4 | @IsOptional() 5 | @Matches(/^[1-9]+$/, { 6 | message: 'Cursor should be integer.', 7 | }) 8 | page: number; 9 | 10 | @IsOptional() 11 | @Matches(/^[1-9]+$/, { 12 | message: 'Limit should be integer.', 13 | }) 14 | limit: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { AppService } from './app.service'; 4 | 5 | @Controller() 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | getHello() { 11 | return 'Hello World!'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { APP_GUARD } from '@nestjs/core'; 4 | import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; 5 | 6 | import { AppController } from './app.controller'; 7 | import { AppService } from './app.service'; 8 | import { AuthModule } from './auth/auth.module'; 9 | import { AdminsModule } from './admins/admins.module'; 10 | import { DatabaseModule } from './database/database.module'; 11 | import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; 12 | import { RolesGuard } from './auth/guards/roles.guard'; 13 | import { FileModule } from './file/file.module'; 14 | import { MyLoggerModule } from './my-logger/my-logger.module'; 15 | 16 | @Module({ 17 | imports: [ 18 | ConfigModule.forRoot({ isGlobal: true }), 19 | AuthModule, 20 | AdminsModule, 21 | DatabaseModule, 22 | ThrottlerModule.forRoot([ 23 | { 24 | name: 'short', 25 | ttl: 1000, // 1 sec 26 | limit: 3, 27 | }, 28 | { 29 | name: 'medium', 30 | ttl: 10000, // 10 sec 31 | limit: 20, 32 | }, 33 | { 34 | name: 'long', 35 | ttl: 60000, // 1 min 36 | limit: 100, 37 | }, 38 | ]), 39 | FileModule, 40 | MyLoggerModule, 41 | ], 42 | controllers: [AppController], 43 | providers: [ 44 | AppService, 45 | { 46 | provide: APP_GUARD, 47 | useClass: ThrottlerGuard, 48 | }, 49 | { 50 | provide: APP_GUARD, 51 | useClass: JwtAuthGuard, 52 | }, 53 | { 54 | provide: APP_GUARD, 55 | useClass: RolesGuard, 56 | }, 57 | ], 58 | }) 59 | export class AppModule {} 60 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService {} 5 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('AuthController', () => { 6 | let controller: AuthController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [AuthController], 11 | providers: [AuthService], 12 | }).compile(); 13 | 14 | controller = module.get(AuthController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | // Get, 4 | Post, 5 | Body, 6 | UseGuards, 7 | // HttpCode, 8 | // HttpStatus, 9 | // Request, 10 | // Patch, 11 | // Param, 12 | // Delete, 13 | } from '@nestjs/common'; 14 | import { Throttle } from '@nestjs/throttler'; 15 | 16 | import { AuthService } from './auth.service'; 17 | import { LocalAuthGuard } from './guards/local-auth.guard'; 18 | import { Public } from './decorators/public.decorator'; 19 | import { User } from './decorators/user.decorator'; 20 | 21 | import { RegisterDto } from './dto/register.dto'; 22 | import { VerifyDto } from './dto/verify.dto'; 23 | import { ConfirmDto } from './dto/confirm.dto'; 24 | 25 | /* 26 | * POST localhost:8080/api/v1/auth/register 27 | * Register an admin using Phone & password only 28 | * In real world, OTP should be used to verify phone number 29 | * But in this app, we will simulate fake OTP - 123456 30 | */ 31 | 32 | @Throttle({ 33 | short: { ttl: 60000, limit: 3 }, 34 | long: { limit: 30, ttl: 60000 * 60 * 24 }, 35 | }) 36 | @Public() 37 | @Controller('v1/auth') 38 | export class AuthController { 39 | constructor(private readonly authService: AuthService) {} 40 | 41 | @UseGuards(LocalAuthGuard) 42 | @Post('login') 43 | async login(@User() user) { 44 | return this.authService.login(user); 45 | } 46 | 47 | @Post('register') 48 | async register(@Body() registerDto: RegisterDto) { 49 | return this.authService.register(registerDto); 50 | } 51 | 52 | @Post('verify') 53 | async verify(@Body() verifyDto: VerifyDto) { 54 | return this.authService.verify(verifyDto); 55 | } 56 | 57 | @Post('confirm') 58 | async confirm(@Body() confirmDto: ConfirmDto) { 59 | return this.authService.confirm(confirmDto); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | 6 | import { AuthService } from './auth.service'; 7 | import { AuthController } from './auth.controller'; 8 | import { LocalStrategy } from './strategies/local.strategy'; 9 | import { JwtStrategy } from './strategies/jwt.strategy'; 10 | import { DatabaseModule } from '../database/database.module'; 11 | import { AdminsModule } from '../admins/admins.module'; 12 | 13 | @Module({ 14 | imports: [ 15 | ConfigModule, 16 | DatabaseModule, 17 | AdminsModule, 18 | PassportModule, 19 | JwtModule.registerAsync({ 20 | imports: [ConfigModule], 21 | useFactory: async (configService: ConfigService) => ({ 22 | secret: configService.get('TOKEN_SECRET'), 23 | signOptions: { expiresIn: '7d' }, // e.g. 30s, 5m, 7d, 24h 24 | }), 25 | inject: [ConfigService], 26 | }), 27 | ], 28 | controllers: [AuthController], 29 | providers: [AuthService, LocalStrategy, JwtStrategy], 30 | exports: [AuthService], 31 | }) 32 | export class AuthModule {} 33 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | UnauthorizedException, 4 | NotFoundException, 5 | BadRequestException, 6 | ForbiddenException, 7 | } from '@nestjs/common'; 8 | import { Admin } from '@prisma/client'; 9 | import { JwtService } from '@nestjs/jwt'; 10 | import * as bcrypt from 'bcrypt'; 11 | import * as moment from 'moment'; 12 | 13 | import { DatabaseService } from '../database/database.service'; 14 | import { AdminsService } from '../admins/admins.service'; 15 | import { RegisterDto } from './dto/register.dto'; 16 | import { VerifyDto } from './dto/verify.dto'; 17 | import { ConfirmDto } from './dto/confirm.dto'; 18 | import { Status } from '../admins/constants/status.enum'; 19 | 20 | const rand = () => Math.random().toString(36).substring(2); 21 | 22 | @Injectable() 23 | export class AuthService { 24 | constructor( 25 | private readonly prisma: DatabaseService, 26 | private adminsService: AdminsService, 27 | private jwtService: JwtService, 28 | ) {} 29 | 30 | // *** Local Strategy for Passport Auth *** 31 | 32 | async validateUser(phone: string, pass: string): Promise { 33 | if (!/^[0-9]+$/.test(phone) || phone.length < 8 || phone.length > 12) { 34 | throw new BadRequestException('Invalid Phone Number!'); 35 | } 36 | if (!/^[0-9]+$/.test(pass) || pass.length != 8) { 37 | throw new BadRequestException('Invalid Password!'); 38 | } 39 | 40 | const admin = await this.adminsService.findByPhone(phone); 41 | if (!admin) { 42 | throw new NotFoundException('This phone number has not registered yet!'); 43 | } 44 | 45 | // Wrong Password allowed 3 times per day 46 | if (admin.status === 'freeze') { 47 | throw new ForbiddenException( 48 | 'Your account is temporarily locked. Please contact us.', 49 | ); 50 | } 51 | 52 | let result; 53 | 54 | const isEqual = await bcrypt.compare(pass, admin.password); 55 | if (!isEqual) { 56 | // ----- Starting to record wrong times -------- 57 | const lastRequest = new Date(admin.updatedAt).toLocaleDateString(); 58 | const isSameDate = lastRequest == new Date().toLocaleDateString(); 59 | 60 | if (!isSameDate) { 61 | const adminData = { 62 | error: 1, 63 | }; 64 | result = await this.adminsService.update(admin.id, adminData); 65 | } else { 66 | if (admin.error >= 2) { 67 | const adminData = { 68 | status: Status.Freeze, 69 | }; 70 | result = await this.adminsService.update(admin.id, adminData); 71 | } else { 72 | const adminData = { 73 | error: { 74 | increment: 1, 75 | }, 76 | }; 77 | result = await this.adminsService.update(admin.id, adminData); 78 | } 79 | } 80 | // ----- Ending ----------- 81 | throw new UnauthorizedException('Password is wrong.'); 82 | } 83 | 84 | const randToken = rand() + rand() + rand(); 85 | if (admin.error >= 1) { 86 | const adminData = { 87 | error: 0, 88 | randToken: randToken, 89 | }; 90 | result = await this.adminsService.update(admin.id, adminData); 91 | } else { 92 | const adminData = { 93 | randToken: randToken, 94 | }; 95 | result = await this.adminsService.update(admin.id, adminData); 96 | } 97 | 98 | return result; 99 | } 100 | 101 | // *** Login with phone & password *** 102 | 103 | async login(user: Admin) { 104 | const payload = { sub: user.id }; 105 | const jwtToken = this.jwtService.sign(payload); 106 | return { 107 | message: 'Successfully Logged In.', 108 | token: jwtToken, 109 | user_id: user.id, 110 | randomToken: user.randToken, 111 | }; 112 | } 113 | 114 | // *** Register with phone - In order to request OTP *** 115 | 116 | async register(registerDto: RegisterDto) { 117 | const phone = registerDto.phone; 118 | const admin = await this.adminsService.findByPhone(phone); 119 | if (admin) { 120 | throw new BadRequestException('This phone number already exists'); 121 | } 122 | // OTP processing eg. Sending OTP request to Operator 123 | const otpCheck = await this.prisma.otp.findUnique({ 124 | where: { phone: phone }, 125 | }); 126 | const token = rand() + rand(); 127 | let result; 128 | const otp = '123456'; 129 | 130 | if (!otpCheck) { 131 | const otpData = { 132 | phone, // phone 133 | otp, // fake OTP 134 | rememberToken: token, 135 | count: 1, 136 | }; 137 | 138 | result = await this.prisma.otp.create({ data: otpData }); 139 | } else { 140 | const lastRequest = new Date(otpCheck.updatedAt).toLocaleDateString(); 141 | const isSameDate = lastRequest == new Date().toLocaleDateString(); 142 | 143 | if (isSameDate && otpCheck.error === 5) { 144 | throw new ForbiddenException( 145 | 'OTP is wrong 5 times today. Try again tomorrow.', 146 | ); 147 | } 148 | 149 | if (!isSameDate) { 150 | const otpData = { 151 | otp, 152 | rememberToken: token, 153 | count: 1, 154 | error: 0, 155 | }; 156 | result = await this.prisma.otp.update({ 157 | where: { id: otpCheck.id }, 158 | data: otpData, 159 | }); 160 | } else { 161 | if (otpCheck.count === 3) { 162 | throw new ForbiddenException( 163 | 'OTP requests are allowed only 3 times per day. Please try again tomorrow,if you reach the limit.', 164 | ); 165 | } else { 166 | const otpData = { 167 | otp, 168 | rememberToken: token, 169 | count: { 170 | increment: 1, 171 | }, 172 | }; 173 | result = await this.prisma.otp.update({ 174 | where: { id: otpCheck.id }, 175 | data: otpData, 176 | }); 177 | } 178 | } 179 | } 180 | 181 | return { 182 | message: `We are sending OTP to 09${result.phone}.`, 183 | phone: result.phone, 184 | token: result.rememberToken, 185 | }; 186 | } 187 | 188 | // *** Verify with phone, token & OTP - In order to go to password confirmation api *** 189 | 190 | async verify(verifyDto: VerifyDto) { 191 | const { phone, token, otp } = verifyDto; 192 | 193 | const admin = await this.adminsService.findByPhone(phone); 194 | if (admin) { 195 | throw new BadRequestException('This phone number already exists'); 196 | } 197 | 198 | const otpCheck = await this.prisma.otp.findUnique({ 199 | where: { phone: phone }, 200 | }); 201 | if (!otpCheck) { 202 | throw new NotFoundException(`OTP was not sent to ${phone}!`); 203 | } 204 | 205 | // Wrong OTP allowed 5 times per day 206 | const lastRequest = new Date(otpCheck!.updatedAt).toLocaleDateString(); 207 | const isSameDate = lastRequest == new Date().toLocaleDateString(); 208 | if (isSameDate && otpCheck.error === 5) { 209 | throw new ForbiddenException( 210 | 'OTP is wrong 5 times today. Try again tomorrow.', 211 | ); 212 | } 213 | 214 | let result; 215 | 216 | if (otpCheck.rememberToken !== token) { 217 | const otpData = { 218 | error: 5, 219 | }; 220 | result = await this.prisma.otp.update({ 221 | where: { id: otpCheck.id }, 222 | data: otpData, 223 | }); 224 | throw new BadRequestException('Token is invalid.'); 225 | } 226 | 227 | const updatedAtMoment = moment(otpCheck.updatedAt); 228 | const currentMoment = moment(); 229 | const difference = currentMoment.diff(updatedAtMoment); 230 | // console.log('Moment Diff:', difference); 231 | 232 | // const difference = moment() - moment(otpCheck!.updatedAt); 233 | 234 | if (difference > 90000) { 235 | // expire at 1 min 30 sec 236 | throw new BadRequestException('OTP is expired.'); 237 | } 238 | 239 | if (otpCheck.otp !== otp) { 240 | // ----- Starting to record wrong times -------- 241 | if (!isSameDate) { 242 | const otpData = { 243 | error: 1, 244 | }; 245 | result = await this.prisma.otp.update({ 246 | where: { id: otpCheck.id }, 247 | data: otpData, 248 | }); 249 | } else { 250 | const otpData = { 251 | error: { 252 | increment: 1, 253 | }, 254 | }; 255 | result = await this.prisma.otp.update({ 256 | where: { id: otpCheck.id }, 257 | data: otpData, 258 | }); 259 | } 260 | // ----- Ending ----------- 261 | throw new BadRequestException('OTP is incorrect.'); 262 | } 263 | 264 | const randomToken = rand() + rand() + rand(); 265 | const otpData = { 266 | verifyToken: randomToken, 267 | count: 1, 268 | error: 1, 269 | }; 270 | result = await this.prisma.otp.update({ 271 | where: { id: otpCheck.id }, 272 | data: otpData, 273 | }); 274 | 275 | return { 276 | message: 'Successfully OTP is verified', 277 | phone: result.phone, 278 | token: result.verifyToken, 279 | }; 280 | } 281 | 282 | // *** Confirm with phone, token & password - In order to create a new admin user *** 283 | 284 | async confirm(confirmDto: ConfirmDto) { 285 | const { phone, token, password } = confirmDto; 286 | 287 | const admin = await this.adminsService.findByPhone(phone); 288 | if (admin) { 289 | throw new BadRequestException('This phone number already exists'); 290 | } 291 | 292 | const otpCheck = await this.prisma.otp.findUnique({ 293 | where: { phone: phone }, 294 | }); 295 | if (!otpCheck) { 296 | throw new NotFoundException(`OTP was not sent to ${phone}!`); 297 | } 298 | 299 | if (otpCheck.error === 5) { 300 | throw new BadRequestException( 301 | 'This request may be an attack. If not, try again tomorrow.', 302 | ); 303 | } 304 | 305 | let result; 306 | 307 | if (otpCheck.verifyToken !== token) { 308 | const otpData = { 309 | error: 5, 310 | }; 311 | result = await this.prisma.otp.update({ 312 | where: { id: otpCheck.id }, 313 | data: otpData, 314 | }); 315 | 316 | throw new BadRequestException( 317 | `Token is invalid. This ${result.phone} is blocked.`, 318 | ); 319 | } 320 | 321 | const updatedAtMoment = moment(otpCheck.updatedAt); 322 | const currentMoment = moment(); 323 | const difference = currentMoment.diff(updatedAtMoment); 324 | // console.log("Diff", difference); 325 | 326 | if (difference > 300000) { 327 | // will expire after 5 min 328 | throw new ForbiddenException( 329 | 'Your request is expired. Please try again.', 330 | ); 331 | } 332 | 333 | const salt = await bcrypt.genSalt(10); 334 | const hashPassword = await bcrypt.hash(password, salt); 335 | const randToken = rand() + rand() + rand(); 336 | 337 | const adminData = { 338 | phone: phone, 339 | password: hashPassword, 340 | status: Status.Active, 341 | randToken: randToken, 342 | }; 343 | const newAdmin = await this.adminsService.create(adminData); 344 | 345 | // jwt token 346 | const payload = { sub: newAdmin.id }; 347 | const jwtToken = this.jwtService.sign(payload); 348 | 349 | return { 350 | message: 'Successfully created an account.', 351 | token: jwtToken, 352 | user_id: newAdmin.id, 353 | randomToken: randToken, 354 | }; 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/auth/constants/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | User = 'user', 3 | Admin = 'admin', 4 | Editor = 'editor', 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic'; 4 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 5 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../constants/role.enum'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /src/auth/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const User = createParamDecorator( 4 | (data: unknown, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | return request.user; 7 | }, 8 | ); 9 | -------------------------------------------------------------------------------- /src/auth/dto/confirm.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; 2 | 3 | export class ConfirmDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @Length(8, 12) 7 | @Matches(/^[0-9]+$/, { 8 | message: 'Invalid phone number.', 9 | }) 10 | phone: string; 11 | 12 | @IsString() 13 | @IsNotEmpty() 14 | token: string; 15 | 16 | @IsString() 17 | @IsNotEmpty() 18 | @Length(8, 8) 19 | @Matches(/^[0-9]+$/, { 20 | message: 'Invalid Password.', 21 | }) 22 | password: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; 2 | 3 | export class RegisterDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @Length(8, 12) 7 | @Matches(/^[0-9]+$/, { 8 | message: 'Invalid phone number.', 9 | }) 10 | phone: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/dto/verify.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; 2 | 3 | export class VerifyDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @Length(8, 12) 7 | @Matches(/^[0-9]+$/, { 8 | message: 'Invalid phone number.', 9 | }) 10 | phone: string; 11 | 12 | @IsString() 13 | @IsNotEmpty() 14 | token: string; 15 | 16 | @IsString() 17 | @IsNotEmpty() 18 | @Length(6, 6) 19 | @Matches(/^[0-9]+$/, { 20 | message: 'Invalid OTP.', 21 | }) 22 | otp: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; 5 | 6 | @Injectable() 7 | export class JwtAuthGuard extends AuthGuard('jwt') { 8 | constructor(private reflector: Reflector) { 9 | super(); 10 | } 11 | 12 | canActivate(context: ExecutionContext) { 13 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 14 | context.getHandler(), 15 | context.getClass(), 16 | ]); 17 | if (isPublic) { 18 | return true; 19 | } 20 | return super.canActivate(context); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { ROLES_KEY } from '../decorators/roles.decorator'; 4 | import { Role } from '../constants/role.enum'; 5 | 6 | @Injectable() 7 | export class RolesGuard implements CanActivate { 8 | constructor(private reflector: Reflector) {} 9 | 10 | canActivate(context: ExecutionContext): boolean { 11 | const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ 12 | context.getHandler(), 13 | context.getClass(), 14 | ]); 15 | if (!requiredRoles) { 16 | return true; 17 | } 18 | const { user } = context.switchToHttp().getRequest(); 19 | // return requiredRoles.some((role) => user.roles?.includes(role)); // roles: Role[]; 20 | return requiredRoles.includes(user.role); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AdminsService } from '../../admins/admins.service'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor( 10 | private readonly configService: ConfigService, 11 | private adminsService: AdminsService, 12 | ) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | ignoreExpiration: false, 16 | secretOrKey: configService.get('TOKEN_SECRET'), 17 | }); 18 | } 19 | 20 | async validate(payload: any) { 21 | const admin = await this.adminsService.findById(payload.sub); 22 | 23 | if (!admin) { 24 | throw new UnauthorizedException(); 25 | } 26 | 27 | return admin; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | 5 | import { AuthService } from '../auth.service'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor(private authService: AuthService) { 10 | super({ usernameField: 'phone' }); // Because 'username' is default, I changed 'phone' 11 | } 12 | 13 | async validate(phone: string, password: string): Promise { 14 | const user = await this.authService.validateUser(phone, password); 15 | if (!user) { 16 | throw new UnauthorizedException(); 17 | } 18 | return user; 19 | } 20 | } 21 | 22 | // The validate method is called when a user attempts to log in. 23 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DatabaseService } from './database.service'; 3 | 4 | @Module({ 5 | providers: [DatabaseService], 6 | exports: [DatabaseService], 7 | }) 8 | export class DatabaseModule {} 9 | -------------------------------------------------------------------------------- /src/database/database.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DatabaseService } from './database.service'; 3 | 4 | describe('PrismaService', () => { 5 | let service: DatabaseService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [DatabaseService], 10 | }).compile(); 11 | 12 | service = module.get(DatabaseService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class DatabaseService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/file/config/multer.config.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { FileFilterCallback, diskStorage } from 'multer'; 3 | 4 | const fileStorage = diskStorage({ 5 | destination: function (req, file, cb) { 6 | cb(null, './public/images'); 7 | 8 | // const ext = file.mimetype.split("/")[0]; 9 | // if (ext === "image") { 10 | // cb(null, "uploads/images"); 11 | // } else { 12 | // cb(null, "uploads/files"); 13 | // } 14 | }, 15 | filename: function (req, file, cb) { 16 | const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); 17 | cb(null, uniqueSuffix + '-' + file.originalname); 18 | }, 19 | }); 20 | 21 | const fileFilter = (req: Request, file: any, cb: FileFilterCallback) => { 22 | if ( 23 | file.mimetype === 'image/png' || 24 | file.mimetype === 'image/jpg' || 25 | file.mimetype === 'image/jpeg' 26 | ) { 27 | cb(null, true); 28 | } else { 29 | cb(null, false); 30 | } 31 | }; 32 | 33 | export const multerOptions = { 34 | storage: fileStorage, 35 | fileFilter: fileFilter, 36 | limits: { fileSize: 1000000 * 2 }, // maximum 2 MB 37 | }; 38 | -------------------------------------------------------------------------------- /src/file/file.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FileController } from './file.controller'; 3 | import { FileService } from './file.service'; 4 | 5 | describe('FileController', () => { 6 | let controller: FileController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [FileController], 11 | providers: [FileService], 12 | }).compile(); 13 | 14 | controller = module.get(FileController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/file/file.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | // Body, 3 | Controller, 4 | // ParseFilePipeBuilder, 5 | Post, 6 | UploadedFile, 7 | UseInterceptors, 8 | BadRequestException, 9 | } from '@nestjs/common'; 10 | import { FileInterceptor } from '@nestjs/platform-express'; 11 | import { Express } from 'express'; 12 | 13 | import { Roles } from '../auth/decorators/roles.decorator'; 14 | import { Role } from '../auth/constants/role.enum'; 15 | import { FileService } from './file.service'; 16 | import { User } from '../auth/decorators/user.decorator'; 17 | 18 | @Controller('v1/file') 19 | @Roles(Role.Editor, Role.Admin) // Other roles can't get access 20 | export class FileController { 21 | constructor(private readonly fileService: FileService) {} 22 | 23 | @UseInterceptors(FileInterceptor('file')) 24 | @Post('upload') 25 | async upload(@User() authUser, @UploadedFile() file: Express.Multer.File) { 26 | if (!file) { 27 | throw new BadRequestException( 28 | 'Fail to upload file. Check it out, please.', 29 | ); 30 | } 31 | return this.fileService.addProfile(authUser, file); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/file/file.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MulterModule } from '@nestjs/platform-express'; 3 | 4 | import { FileService } from './file.service'; 5 | import { FileController } from './file.controller'; 6 | import { AdminsModule } from '../admins/admins.module'; 7 | import { multerOptions } from './config/multer.config'; 8 | 9 | @Module({ 10 | imports: [MulterModule.register(multerOptions), AdminsModule], 11 | controllers: [FileController], 12 | providers: [FileService], 13 | exports: [FileService], // Make FileService available to other modules 14 | }) 15 | export class FileModule {} 16 | -------------------------------------------------------------------------------- /src/file/file.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FileService } from './file.service'; 3 | 4 | describe('FileService', () => { 5 | let service: FileService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [FileService], 10 | }).compile(); 11 | 12 | service = module.get(FileService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/file/file.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Express } from 'express'; 3 | import { unlink } from 'node:fs/promises'; 4 | 5 | import { AdminsService } from '../admins/admins.service'; 6 | import { MyLoggerService } from '../my-logger/my-logger.service'; 7 | 8 | @Injectable() 9 | export class FileService { 10 | constructor(private adminsService: AdminsService) {} 11 | private readonly logger = new MyLoggerService(FileService.name); 12 | 13 | async addProfile(admin: any, file: Express.Multer.File) { 14 | const imageUrl = file.path.replace('\\', '/'); 15 | if (admin.profile) { 16 | // Delete an old profile image because it accepts just one. 17 | 18 | try { 19 | await unlink(admin.profile); 20 | } catch (error) { 21 | this.logger.log( 22 | ` - Profile file is missing in this ${admin.phone}`, 23 | FileService.name, 24 | ); 25 | const adminData = { 26 | profile: imageUrl, 27 | }; 28 | await this.adminsService.update(admin.id, adminData); 29 | } 30 | } 31 | const adminData = { 32 | profile: imageUrl, 33 | }; 34 | await this.adminsService.update(admin.id, adminData); 35 | 36 | return { message: 'Successfully uploaded the image.', profile: imageUrl }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory, Reflector } from '@nestjs/core'; 2 | import { ValidationPipe } from '@nestjs/common'; 3 | import * as compression from 'compression'; 4 | import { AppModule } from './app.module'; 5 | import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; 6 | import { RolesGuard } from './auth/guards/roles.guard'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector))); 11 | app.useGlobalGuards(new RolesGuard(app.get(Reflector))); 12 | app.useGlobalPipes(new ValidationPipe({ whitelist: true })); 13 | app.use(compression()); 14 | app.enableCors(); 15 | app.setGlobalPrefix('api'); 16 | await app.listen(3000); 17 | } 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /src/my-logger/my-logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MyLoggerService } from './my-logger.service'; 3 | 4 | @Module({ 5 | providers: [MyLoggerService], 6 | exports: [MyLoggerService], 7 | }) 8 | export class MyLoggerModule {} 9 | -------------------------------------------------------------------------------- /src/my-logger/my-logger.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MyLoggerService } from './my-logger.service'; 3 | 4 | describe('MyLoggerService', () => { 5 | let service: MyLoggerService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MyLoggerService], 10 | }).compile(); 11 | 12 | service = module.get(MyLoggerService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/my-logger/my-logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ConsoleLogger } from '@nestjs/common'; 2 | import * as fs from 'fs'; 3 | import { promises as fsPromises } from 'fs'; 4 | import * as path from 'path'; 5 | 6 | @Injectable() 7 | export class MyLoggerService extends ConsoleLogger { 8 | async logToFile(entry) { 9 | const formattedEntry = `${Intl.DateTimeFormat('en-US', { 10 | dateStyle: 'short', 11 | timeStyle: 'short', 12 | timeZone: 'Asia/Yangon', 13 | }).format(new Date())}\t${entry}\n`; 14 | 15 | try { 16 | if (!fs.existsSync(path.join(__dirname, '..', '..', 'logs'))) { 17 | await fsPromises.mkdir(path.join(__dirname, '..', '..', 'logs')); 18 | } 19 | await fsPromises.appendFile( 20 | path.join(__dirname, '..', '..', 'logs', 'myLogFile.log'), 21 | formattedEntry, 22 | ); 23 | } catch (e) { 24 | if (e instanceof Error) console.error(e.message); 25 | } 26 | } 27 | 28 | log(message: any, context?: string) { 29 | const entry = `${context}\t${message}`; 30 | this.logToFile(entry); 31 | super.log(message, context); 32 | } 33 | 34 | error(message: any, stackOrContext?: string) { 35 | const entry = `${stackOrContext}\t${message}`; 36 | this.logToFile(entry); 37 | super.error(message, stackOrContext); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------