├── .dockerignore ├── .env.ex ├── .env.test ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── README.md ├── config-env.sh ├── docker-compose.yml ├── github └── swagger.png ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── prisma └── schema.prisma ├── src ├── app.module.ts ├── configuration.ts ├── document.ts ├── main.ts ├── modules │ ├── auth │ │ ├── __test__ │ │ │ └── auth.service.spec.ts │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── constants │ │ │ └── jwt.constant.ts │ │ ├── dtos │ │ │ ├── signin.dto.ts │ │ │ └── signup.dto.ts │ │ └── strategy │ │ │ └── jwt.strategy.ts │ ├── categories │ │ ├── categories.controller.ts │ │ ├── categories.module.ts │ │ ├── categories.repository.ts │ │ ├── categories.service.ts │ │ └── dtos │ │ │ ├── create.dto.ts │ │ │ └── update.dto.ts │ ├── comments │ │ ├── __test__ │ │ │ └── comments.service.spec.ts │ │ ├── comments.controller.ts │ │ ├── comments.module.ts │ │ ├── comments.repository.ts │ │ ├── comments.service.ts │ │ └── dtos │ │ │ ├── create.dto.ts │ │ │ └── query.dto.ts │ ├── logging │ │ ├── __test__ │ │ │ └── loggingService.spec.ts │ │ ├── constants │ │ │ └── dep_keys.constant.ts │ │ ├── loggers │ │ │ └── console.logger.ts │ │ ├── logging.module.ts │ │ └── logging.service.ts │ ├── mail │ │ ├── mail.module.ts │ │ └── mail.service.ts │ ├── post │ │ ├── __test__ │ │ │ └── post.service.spec.ts │ │ ├── dtos │ │ │ ├── createPost.dto.ts │ │ │ ├── search.dto.ts │ │ │ └── updatePost.dto.ts │ │ ├── models │ │ │ ├── author.model.ts │ │ │ └── post.model.ts │ │ ├── post.controller.ts │ │ ├── post.module.ts │ │ ├── post.repository.ts │ │ ├── post.resolver.ts │ │ ├── post.service.ts │ │ └── post.utility.ts │ ├── prisma │ │ ├── prisma.module.ts │ │ └── prisma.service.ts │ ├── queues │ │ ├── consumers │ │ │ ├── delete-file.consumer.ts │ │ │ ├── reSize-file.consumer.ts │ │ │ └── send-welcome.consumer.ts │ │ └── queues.module.ts │ ├── upload │ │ ├── filters │ │ │ └── post.filter.ts │ │ ├── resize.service.ts │ │ ├── upload.controller.ts │ │ ├── upload.module.ts │ │ ├── upload.service.ts │ │ └── upload.storages.ts │ └── users │ │ ├── __test__ │ │ └── users.service.spec.ts │ │ ├── dtos │ │ └── role.dto.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.repository.ts │ │ └── users.service.ts └── shared │ ├── config │ └── multer.config.ts │ ├── constants │ ├── mail.constant.ts │ ├── messages.constant.ts │ └── queues.constant.ts │ ├── decorators │ ├── api-File.decorator.ts │ └── req-user.decorator.ts │ ├── guards │ ├── auth.guard.ts │ └── check-roles.guard.ts │ ├── interceptors │ └── response.interceptor.ts │ ├── interfaces │ ├── categories.interface.ts │ ├── comment.interface.ts │ ├── mail.interface.ts │ ├── messageLogger.interface.ts │ ├── post.interface.ts │ ├── queues.interface.ts │ ├── repository.interface.ts │ ├── role.interface.ts │ └── user.interface.ts │ └── utils │ └── fileValidator.util.ts ├── templates └── welcome.ejs ├── test ├── auth.e2e-spec.ts ├── config.ts ├── data │ └── user.ts ├── fixtures │ ├── createJwt.fixture.ts │ ├── createUser.fixture.ts │ └── startapp.fixture.ts ├── jest-e2e.config.ts ├── jest-e2e.json └── users.e2e-spec.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .dockerignore 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /.env.ex: -------------------------------------------------------------------------------- 1 | EMAIL_HOST= # you can use https://mailtrap.io/ 2 | EMAIL_PORT= # you can use https://mailtrap.io/ 3 | EMAIL_USER= # you can use https://mailtrap.io/ 4 | EMAIL_PASS= # you can use https://mailtrap.io/ 5 | APP_MODE=development #or production 6 | DATABASE_URL="mysql://...." 7 | REDIS_URL="...." -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://test:test@localhost:3308/test" 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .vscode 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | uploads/* 18 | package-lock.json 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | 39 | db-data 40 | my-uploads 41 | .env 42 | src/schema.gql -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | WORKDIR /usr/blog 4 | 5 | RUN apk add --update --no-cache openssl1.1-compat 6 | 7 | COPY package.json ./ 8 | COPY pnpm-lock.yaml ./ 9 | 10 | 11 | 12 | RUN npm install pnpm -g; \ 13 | pnpm install 14 | 15 | COPY . ./ 16 | 17 | 18 | CMD ["npm","run","start"] 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

9 | Blog - NestJS 10 |

11 | 12 | ## ⚗️technologies 13 | 14 | 25 | 26 | # 🤗 Easy To Run !! 27 | ```bash 28 | # just run bash file 29 | $ ./config-env.sh 30 | ``` 31 | 32 | # 📦 Docker 33 | ```bash 34 | docker-compose --profile product up -d 35 | > Host port 5000 36 | ``` 37 | 38 | 39 | ## 📥Installation 40 | 41 | ```bash 42 | $ npm install 43 | ``` 44 | 45 | ## ⚙️Running the app 46 | 47 | ```bash 48 | 49 | # development 50 | $ npm run start 51 | 52 | # watch mode 53 | $ npm run start:dev 54 | 55 | # production mode 56 | $ npm run start:prod 57 | 58 | # e2e testing 59 | $ npm run test:e2e 60 | ``` 61 | # 📝documents 62 | 63 | ### Database Diagram : 64 | - https://dbdiagram.io/d/636413aec9abfc6111702982 65 | 66 | 67 | ### swagger-ui 68 | - http://localhost:{port}/api 69 | 70 | ![Swagger](/github/swagger.png) 71 | -------------------------------------------------------------------------------- /config-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | red=$(tput setaf 1) 3 | green=$(tput setaf 2) 4 | reset=$(tput sgr0) 5 | space() { 6 | for ((i = 1; i <= $1; i++)); do 7 | echo 8 | done 9 | } 10 | 11 | echo " Blog Web API with NestJs " 12 | space 5 13 | echo "config .env file" 14 | space 3 15 | 16 | echo "Config Email Service (you can use https://mailtrap.io/)" 17 | echo 18 | read -p "Enter EMAIL_HOST: " EMAIL_HOST 19 | read -p "Enter EMAIL_PORT: " EMAIL_PORT 20 | read -p "Enter EMAIL_USER: " EMAIL_USER 21 | read -p "Enter EMAIL_PASS: " EMAIL_PASS 22 | 23 | space 2 24 | echo "Config Database's" 25 | space 2 26 | read -p "Enter MySQL database name: " MYSQL_DATABASE 27 | read -p "Enter MySQL user: " MYSQL_USER 28 | read -p "Enter MySQL password: " MYSQL_PASSWORD 29 | read -p "Enter MySqL port(press Enter for default 3306):" MYSQL_PORT 30 | MYSQL_PORT=${MYSQL_PORT:-3306} 31 | MYSQL_ROOT_PASSWORD=$(openssl rand -base64 32) 32 | echo "generated Random password for root" 33 | space 3 34 | read -p "Enter Redis url: " REDIS_URL 35 | space 2 36 | read -p "Enter port number (press Enter for default 3000): " PORT 37 | PORT=${PORT:-3000} 38 | read -p "Enter app mode[development,production] (press Enter for development): " APP_MODE 39 | APP_MODE=${APP_MODE:-DEVELOPMENT} 40 | 41 | echo "EMAIL_HOST=$EMAIL_HOST" >.env 42 | echo "EMAIL_PORT=$EMAIL_PORT" >>.env 43 | echo "EMAIL_USER=$EMAIL_USER" >>.env 44 | echo "EMAIL_PASS=$EMAIL_PASS" >>.env 45 | echo "DATABASE_URL=mysql://$MYSQL_USER:$MYSQL_PASSWORD@mysqldb:$MYSQL_PORT/$MYSQL_DATABASE" >>.env 46 | echo "REDIS_URL=$REDIS_URL" >>.env 47 | echo "PORT=$PORT" >>.env 48 | echo "APP_MODE=$APP_MODE" >>.env 49 | echo "MYSQL_USER=$MYSQL_USER" >>.env 50 | echo "MYSQL_PASSWORD=$MYSQL_PASSWORD" >>.env 51 | echo "MYSQL_DATABASE=$MYSQL_DATABASE" >>.env 52 | echo "MYSQL_PORT=$MYSQL_PORT" >>.env 53 | echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 12)" >>.env 54 | echo "JWT_SECRET=$(openssl rand -hex 12)" >>.env 55 | echo "${green}Values saved to .env file${reset}" 56 | space 3 57 | 58 | read -p "Do you want to run '${green}docker-compose --profile product up -d${reset}' automatically? [y/n]: " REPLY 59 | 60 | if [[ $REPLY =~ ^[Yy]$ ]]; then 61 | # Automatically run `docker-compose up -d` 62 | docker-compose --profile product up -d 63 | else 64 | # Prompt the user to run `docker-compose up -d` manually 65 | echo "Run 'docker-compose --profile product up -d' manually to start the application. ${red}[Exiting...]${reset}" 66 | sleep 3 67 | fi 68 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | redis: 6 | image: redis 7 | expose: 8 | - 6379 9 | profiles: 10 | - product 11 | 12 | app: 13 | build: 14 | context: . 15 | dockerfile: Dockerfile 16 | depends_on: 17 | - redis 18 | - mysqldb 19 | env_file: 20 | - .env 21 | ports: 22 | - "${PORT}:${PORT}" 23 | environment: 24 | DATABASE_URL: "mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysqldb:3306/${MYSQL_DATABASE}" 25 | REDIS_URL: redis 26 | volumes: 27 | - ./my-uploads:/usr/blog/uploads 28 | profiles: 29 | - product 30 | 31 | mysqldb: 32 | image: mysql:5.7 33 | env_file: 34 | - .env 35 | ports: 36 | - "${MYSQL_PORT}:3306" 37 | expose: 38 | - 3306 39 | environment: 40 | MYSQL_DATABASE: "${MYSQL_DATABASE}" 41 | MYSQL_USER: "${MYSQL_USER}" 42 | MYSQL_PASSWORD: "${MYSQL_PASSWORD}" 43 | MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" 44 | SERVICE_TAGS: prod 45 | SERVICE_NAME: mysqldb 46 | volumes: 47 | - ./db-data:/var/lib/mysql 48 | profiles: 49 | - product 50 | 51 | mysqldb-test: 52 | image: mysql:5.7 53 | ports: 54 | - '3308:3306' 55 | environment: 56 | MYSQL_DATABASE: blog 57 | MYSQL_USER: prisma 58 | MYSQL_PASSWORD: prisma 59 | MYSQL_ROOT_PASSWORD: prisma 60 | SERVICE_TAGS: prod 61 | SERVICE_NAME: mysqldb 62 | profiles: 63 | - test 64 | 65 | volumes: 66 | mysql-data: 67 | my-uploads: 68 | -------------------------------------------------------------------------------- /github/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sajjadmrx/Blog-nestJs/3786691be8a9aea5419fc30dd50ce2ab7f0e18ef/github/swagger.png -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-nestjs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "npx prisma generate && npx prisma db push && nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "prisma:test:reset": "dotenv -e ./.env.test -- prisma migrate reset --force && dotenv -e ./.env.test -- prisma db push", 22 | "test:e2e": "dotenv -e .env.test -- npx prisma db push && dotenv -e .env.test -- jest --config ./test/jest-e2e.json" 23 | }, 24 | "dependencies": { 25 | "@nestjs-modules/mailer": "^1.8.1", 26 | "@nestjs/apollo": "^10.1.6", 27 | "@nestjs/bull": "^0.6.0", 28 | "@nestjs/common": "9.3.9", 29 | "@nestjs/config": "^2.2.0", 30 | "@nestjs/core": "9.3.9", 31 | "@nestjs/graphql": "^10.1.6", 32 | "@nestjs/jwt": "^8.0.0", 33 | "@nestjs/passport": "^8.2.1", 34 | "@nestjs/platform-express": "^8.0.0", 35 | "@nestjs/swagger": "^5.2.1", 36 | "@prisma/client": "4.6.1", 37 | "apollo-server-express": "^3.11.1", 38 | "bcrypt": "^5.0.1", 39 | "best-string": "^1.0.0", 40 | "bull": "^4.8.5", 41 | "chalk": "4.1.2", 42 | "class-transformer": "^0.5.1", 43 | "class-validator": "^0.13.2", 44 | "dotenv-cli": "^6.0.0", 45 | "graphql": "^16.6.0", 46 | "multer": "1.4.5-lts.1", 47 | "nanoid": "^3.3.4", 48 | "nodemailer": "^6.7.7", 49 | "passport": "^0.5.2", 50 | "passport-jwt": "^4.0.0", 51 | "prisma": "4.2.0", 52 | "reflect-metadata": "^0.1.13", 53 | "rimraf": "^3.0.2", 54 | "rxjs": "^7.2.0", 55 | "sharp": "^0.30.4", 56 | "swagger-ui-express": "^4.3.0", 57 | "typeorm": "^0.2.44", 58 | "uuid": "^9.0.0" 59 | }, 60 | "devDependencies": { 61 | "@jest/types": "^29.3.1", 62 | "@nestjs/cli": "^8.0.0", 63 | "@nestjs/schematics": "^8.0.0", 64 | "@nestjs/testing": "^8.4.7", 65 | "@types/bcrypt": "^5.0.0", 66 | "@types/bull": "^3.15.9", 67 | "@types/express": "^4.17.13", 68 | "@types/jest": "27.0.2", 69 | "@types/multer": "^1.4.7", 70 | "@types/node": "^16.0.0", 71 | "@types/sharp": "^0.30.2", 72 | "@types/supertest": "^2.0.12", 73 | "@typescript-eslint/eslint-plugin": "^5.0.0", 74 | "@typescript-eslint/parser": "^5.0.0", 75 | "eslint": "^8.0.1", 76 | "eslint-config-prettier": "^8.3.0", 77 | "eslint-plugin-prettier": "^4.0.0", 78 | "jest": "^27.2.5", 79 | "jest-mock-extended": "2.0.4", 80 | "prettier": "^2.3.2", 81 | "source-map-support": "^0.5.20", 82 | "supertest": "^6.2.4", 83 | "ts-jest": "^27.0.3", 84 | "ts-loader": "^9.2.3", 85 | "ts-node": "^10.0.0", 86 | "tsconfig-paths": "^3.10.1", 87 | "typescript": "^4.3.5" 88 | }, 89 | "jest": { 90 | "moduleFileExtensions": [ 91 | "js", 92 | "json", 93 | "ts" 94 | ], 95 | "rootDir": "src", 96 | "testRegex": ".*\\.spec\\.ts$", 97 | "transform": { 98 | "^.+\\.(t|j)s$": "ts-jest" 99 | }, 100 | "collectCoverageFrom": [ 101 | "**/*.(t|j)s" 102 | ], 103 | "moduleNameMapper": { 104 | "^src/(.*)$": "/$1" 105 | }, 106 | "verbose": true, 107 | "testTimeout": 1000000, 108 | "coverageDirectory": "../coverage", 109 | "testEnvironment": "node" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /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 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | enum Role { 14 | ADMIN 15 | MANAGE_POSTS 16 | MANAGE_COMMENTS 17 | USER 18 | } 19 | 20 | model User { 21 | id Int @id @default(autoincrement()) 22 | posts Post[] 23 | username String @unique 24 | email String @unique 25 | password String 26 | role Role @default(USER) 27 | createdAt DateTime @default(now()) 28 | updatedAt DateTime @updatedAt 29 | Comment Comment[] 30 | } 31 | 32 | model Post { 33 | id Int @id @default(autoincrement()) 34 | title String 35 | content String 36 | author User @relation(fields: [authorId], references: [id],onDelete: Cascade, onUpdate: Cascade) 37 | authorId Int 38 | published Boolean 39 | cover String //@default("/images/default-post.png") 40 | createdAt DateTime @default(now()) 41 | updatedAt DateTime @updatedAt 42 | Comment Comment[] 43 | categories CategoriesOnPosts[] 44 | tags String @default("[]") 45 | } 46 | 47 | model Category { 48 | id Int @id @unique @default(autoincrement()) 49 | name String 50 | slug String @unique 51 | createdAt DateTime @default(now()) 52 | updatedAt DateTime @updatedAt 53 | // Post Post? @relation(fields: [postId], references: [id]) 54 | Post CategoriesOnPosts[] 55 | } 56 | 57 | model CategoriesOnPosts { 58 | post Post @relation(fields: [postId], references: [id],onDelete: Cascade, onUpdate: Cascade) 59 | postId Int // relation scalar field (used in the `@relation` attribute above) 60 | category Category @relation(fields: [categoryId], references: [id],onDelete: Cascade, onUpdate: Cascade) 61 | categoryId Int // relation scalar field (used in the `@relation` attribute above) 62 | assignedAt DateTime @default(now()) 63 | 64 | @@id([postId, categoryId]) 65 | } 66 | 67 | model Comment { 68 | id Int @id @default(autoincrement()) 69 | text String 70 | author User @relation(fields: [authorId], references: [id],onDelete: Cascade, onUpdate: Cascade ) 71 | authorId Int 72 | post Post @relation(fields: [postId], references: [id],onDelete: Cascade, onUpdate: Cascade ) 73 | postId Int 74 | createdAt DateTime @default(now()) 75 | updatedAt DateTime @updatedAt 76 | replyId Int? 77 | reply Comment? @relation("replies", fields: [replyId], references: [id],onDelete: Cascade, onUpdate: Cascade) 78 | childs Comment[] @relation("replies") //https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations 79 | } 80 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { AuthModule } from "./modules/auth/auth.module"; 4 | import { CategoriesModule } from "./modules/categories/categories.module"; 5 | import { PostModule } from "./modules/post/post.module"; 6 | import { PrismaModule } from "./modules/prisma/prisma.module"; 7 | import { UploadModule } from "./modules/upload/upload.module"; 8 | import { UserModule } from "./modules/users/users.module"; 9 | import { MailModule } from "./modules/mail/mail.module"; 10 | import { ConfigModule, ConfigService } from "@nestjs/config"; 11 | import Configuration from "./configuration"; 12 | import { QueuesModule } from "./modules/queues/queues.module"; 13 | import { CommentsModule } from "./modules/comments/comments.module"; 14 | import { GraphQLModule } from "@nestjs/graphql"; 15 | import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; 16 | import { join } from "path"; 17 | 18 | @Module({ 19 | imports: [ 20 | ConfigModule.forRoot({ 21 | load: [Configuration], 22 | isGlobal: true, 23 | }), 24 | QueuesModule, 25 | PrismaModule, 26 | AuthModule, 27 | UserModule, 28 | PostModule, 29 | UploadModule, 30 | CategoriesModule, 31 | MailModule, 32 | CommentsModule, 33 | GraphQLModule.forRoot({ 34 | driver: ApolloDriver, 35 | autoSchemaFile: join(process.cwd(), "src/schema.gql"), 36 | }), 37 | ], 38 | controllers: [], 39 | providers: [], 40 | }) 41 | export class AppModule {} 42 | -------------------------------------------------------------------------------- /src/configuration.ts: -------------------------------------------------------------------------------- 1 | export interface Configs { 2 | PORT: number; 3 | DATABASE_URL: string; 4 | EMAIL_HOST: string; 5 | EMAIL_PORT: string; 6 | EMAIL_USER: string; 7 | EMAIL_PASS: string; 8 | REDIS_URL: string; 9 | APP_MODE: string; 10 | JWT_SECRET: string; 11 | } 12 | export default (): Configs => ({ 13 | PORT: Number(process.env.PORT), 14 | DATABASE_URL: process.env.DATABASE_URL, 15 | EMAIL_HOST: process.env.EMAIL_HOST, 16 | EMAIL_PORT: process.env.EMAIL_PORT, 17 | EMAIL_USER: process.env.EMAIL_USER, 18 | EMAIL_PASS: process.env.EMAIL_PASS, 19 | REDIS_URL: process.env.REDIS_URL, 20 | APP_MODE: process.env.APP_MODE, 21 | JWT_SECRET: process.env.JWT_SECRET, 22 | }); 23 | -------------------------------------------------------------------------------- /src/document.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; 3 | 4 | export function setupDocument(app: INestApplication, route: string) { 5 | const configDocument = new DocumentBuilder() 6 | .setTitle("Blog - NestJS") 7 | .setDescription("The Blog API description") 8 | .setVersion("1.0") 9 | .addBearerAuth({ 10 | type: "http", 11 | scheme: "bearer", 12 | bearerFormat: "JWT", 13 | }) 14 | .build(); 15 | 16 | const document = SwaggerModule.createDocument(app, configDocument); 17 | SwaggerModule.setup(route, app, document); 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from "@nestjs/common"; 2 | import { NestFactory } from "@nestjs/core"; 3 | import { AppModule } from "./app.module"; 4 | import { NestExpressApplication } from "@nestjs/platform-express"; 5 | import { setupDocument } from "./document"; 6 | import { ConfigService } from "@nestjs/config"; 7 | import { Configs } from "./configuration"; 8 | 9 | (async () => { 10 | const app = await NestFactory.create(AppModule, { 11 | cors: true, 12 | }); 13 | 14 | app.useGlobalPipes(new ValidationPipe()); 15 | 16 | app.setGlobalPrefix("api/v1"); 17 | 18 | app.useStaticAssets("uploads", { 19 | prefix: "/uploads/", 20 | }); 21 | 22 | const configService: ConfigService = new ConfigService(); 23 | 24 | const port = configService.get("PORT") || 3000; 25 | 26 | const isDevelopmentMode: boolean = 27 | configService.get("APP_MODE").toUpperCase() == "DEVELOPMENT"; 28 | 29 | const DOCUMENT_ROUTE: string = "/api"; 30 | 31 | if (isDevelopmentMode) setupDocument(app, DOCUMENT_ROUTE); 32 | 33 | await app.listen(port); 34 | 35 | console.log(`Server running on ${port}`); 36 | 37 | const appUrl: string = isDevelopmentMode 38 | ? `http://127.0.0.1:${port}` 39 | : await app.getUrl(); 40 | 41 | console.log(`GraphQl: ${appUrl}/graphql`); 42 | 43 | isDevelopmentMode && 44 | console.log(`RestApi: http://localhost:${port}${DOCUMENT_ROUTE}`); 45 | })(); 46 | -------------------------------------------------------------------------------- /src/modules/auth/__test__/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from "@nestjs/testing"; 2 | import { AuthService } from "../auth.service"; 3 | import { JwtModule, JwtService } from "@nestjs/jwt"; 4 | import { Prisma, User } from "@prisma/client"; 5 | import { UsersRepository } from "../../users/users.repository"; 6 | import { Queue } from "bull"; 7 | import { welcomeEmailQueue } from "../../../shared/interfaces/queues.interface"; 8 | import { BadRequestException, UnauthorizedException } from "@nestjs/common"; 9 | import bcrypt, { compare } from "bcrypt"; 10 | const user: User = { 11 | username: "mrx", 12 | email: "mrx@gmail.com", 13 | role: "ADMIN", 14 | id: 1, 15 | password: "hash", 16 | createdAt: new Date(), 17 | updatedAt: new Date(), 18 | }; 19 | 20 | const input = { 21 | username: "mrx", 22 | email: "mrx@email.com", 23 | password: "ok", 24 | }; 25 | 26 | describe("AuthService", function () { 27 | let authService: AuthService; 28 | let usersRepository: UsersRepository; 29 | let jwtService: JwtService; 30 | let sendWelcomeEmailQueue; 31 | beforeEach(async () => { 32 | usersRepository = new UsersRepository(jest.fn() as unknown as any); 33 | jwtService = new JwtService(); 34 | sendWelcomeEmailQueue = { 35 | add: jest.fn(), 36 | } as unknown as Queue; 37 | authService = new AuthService( 38 | usersRepository, 39 | jwtService, 40 | sendWelcomeEmailQueue, 41 | jest.fn() as any 42 | ); 43 | }); 44 | 45 | it("should Defined", () => { 46 | expect(authService).toBeDefined(); 47 | }); 48 | 49 | describe("signUp", function () { 50 | it("should throw error user exist", async () => { 51 | jest 52 | .spyOn(usersRepository, "findByEmailOrUsername") 53 | .mockImplementation(async (email: string, username: string) => [user]); 54 | 55 | await expect(authService.signUp(input)).rejects.toEqual( 56 | new BadRequestException("Email or username already exist") 57 | ); 58 | }); 59 | 60 | it("should create User & return jwt code", async () => { 61 | jest 62 | .spyOn(usersRepository, "findByEmailOrUsername") 63 | .mockImplementation(async (email: string, username: string) => []); 64 | jest 65 | .spyOn(usersRepository, "create") 66 | .mockImplementation(async () => user); 67 | const token = "abcdvdfsminewrhnfqbetwetfiw"; 68 | jest.spyOn(jwtService, "sign").mockReturnValue(token); 69 | 70 | await expect(authService.signUp(input)).resolves.toBe(token); 71 | }); 72 | 73 | it("should add welcome email to queue", async () => { 74 | jest 75 | .spyOn(usersRepository, "findByEmailOrUsername") 76 | .mockImplementation(async (email: string, username: string) => []); 77 | jest 78 | .spyOn(usersRepository, "create") 79 | .mockImplementation(async () => user); 80 | const token = "abcdvdfsminewrhnfqbetwetfiw"; 81 | jest.spyOn(jwtService, "sign").mockReturnValue(token); 82 | 83 | await authService.signUp(input); 84 | await expect(sendWelcomeEmailQueue.add).toBeCalled(); 85 | }); 86 | }); 87 | 88 | describe("signIn", function () { 89 | it("should reject 'invalid credentials' when user not found", async () => { 90 | jest 91 | .spyOn(usersRepository, "findOneByUsername") 92 | .mockImplementation(() => null); 93 | await expect( 94 | authService.signIn({ 95 | username: input.username, 96 | password: input.password, 97 | }) 98 | ).rejects.toEqual(new UnauthorizedException("invalid credentials")); 99 | }); 100 | it("should reject 'invalid credentials' when password not Matching", async () => { 101 | jest 102 | .spyOn(usersRepository, "findOneByUsername") 103 | .mockImplementation(() => Promise.resolve(user)); 104 | jest 105 | .spyOn(bcrypt, "compare") 106 | .mockImplementation(async (pass: string, hash: string) => 107 | Promise.resolve(false) 108 | ); 109 | await expect( 110 | authService.signIn({ 111 | username: input.username, 112 | password: input.password, 113 | }) 114 | ).rejects.toEqual(new UnauthorizedException("invalid credentials")); 115 | }); 116 | it("should return jwt code when password is Matching", async () => { 117 | jest 118 | .spyOn(usersRepository, "findOneByUsername") 119 | .mockImplementation(async () => user); 120 | 121 | jest.spyOn(bcrypt, "compare").mockImplementation(async () => true); 122 | 123 | jest.spyOn(jwtService, "sign").mockImplementation(() => "token"); 124 | 125 | await expect(authService.signIn(input)).resolves.toBe("token"); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | Post, 6 | UseInterceptors, 7 | } from "@nestjs/common"; 8 | import { ApiOperation, ApiTags } from "@nestjs/swagger"; 9 | import { SignInDto } from "src/modules/auth/dtos/signin.dto"; 10 | import { AuthService } from "./auth.service"; 11 | import { SignUpDto } from "./dtos/signup.dto"; 12 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 13 | 14 | @ApiTags("Auth") 15 | @UseInterceptors(ResponseInterceptor) 16 | @Controller("auth") 17 | export class AuthController { 18 | constructor(private authService: AuthService) {} 19 | 20 | @ApiOperation({ 21 | summary: "signup", 22 | }) 23 | @Post("signup") 24 | async signup(@Body() body: SignUpDto): Promise { 25 | return await this.authService.signUp(body); 26 | } 27 | 28 | @ApiOperation({ 29 | summary: "signing", 30 | }) 31 | @Post("signing") 32 | @HttpCode(200) 33 | async signing(@Body() body: SignInDto): Promise { 34 | return await this.authService.signIn(body); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { JwtModule } from "@nestjs/jwt"; 3 | import { UserModule } from "../users/users.module"; 4 | import { AuthController } from "./auth.controller"; 5 | import { AuthService } from "./auth.service"; 6 | import { JwtStrategy } from "./strategy/jwt.strategy"; 7 | const ImportsAndExports = [ 8 | JwtModule.register({ signOptions: { expiresIn: "10d" } }), 9 | ]; 10 | @Module({ 11 | imports: [...ImportsAndExports, UserModule], 12 | controllers: [AuthController], 13 | providers: [AuthService, JwtStrategy], 14 | exports: [...ImportsAndExports, AuthService, JwtStrategy], 15 | }) 16 | export class AuthModule {} 17 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | BadRequestException, 4 | UnauthorizedException, 5 | } from "@nestjs/common"; 6 | 7 | import { SignInDto } from "./dtos/signin.dto"; 8 | 9 | import * as bcrypt from "bcrypt"; 10 | import { SignUpDto } from "./dtos/signup.dto"; 11 | import { JwtService } from "@nestjs/jwt"; 12 | import { UsersRepository } from "../users/users.repository"; 13 | import { InjectQueue } from "@nestjs/bull"; 14 | import { QueuesConstant } from "../../shared/constants/queues.constant"; 15 | import { Queue } from "bull"; 16 | import { welcomeEmailQueue } from "../../shared/interfaces/queues.interface"; 17 | import { ConfigService } from "@nestjs/config"; 18 | import { Configs } from "../../configuration"; 19 | 20 | @Injectable() 21 | export class AuthService { 22 | constructor( 23 | private userRepository: UsersRepository, 24 | private jwtService: JwtService, 25 | @InjectQueue(QueuesConstant.SEND_WELCOME_EMAIL) 26 | private queueSendWelcomeEmail: Queue, 27 | private configService: ConfigService 28 | ) {} 29 | 30 | async signUp(userDto: SignUpDto) { 31 | try { 32 | const usersExist = await this.userRepository.findByEmailOrUsername( 33 | userDto.email, 34 | userDto.username 35 | ); 36 | if (usersExist.length > 0) 37 | throw new BadRequestException("Email or username already exist"); 38 | 39 | let newUser = { 40 | ...userDto, 41 | }; 42 | 43 | newUser.password = await bcrypt.hash(newUser.password, 10); 44 | 45 | const user = await this.userRepository.create(newUser); 46 | 47 | await this.queueSendWelcomeEmail.add({ user: user }); 48 | return this.jwtSignUserId(user.id); 49 | } catch (error) { 50 | throw error; 51 | } 52 | } 53 | 54 | async signIn(user: SignInDto) { 55 | try { 56 | const userExist = await this.userRepository.findOneByUsername( 57 | user.username 58 | ); 59 | if (!userExist) throw new UnauthorizedException("invalid credentials"); 60 | 61 | const passwordIsMatch = await bcrypt.compare( 62 | user.password, 63 | userExist.password 64 | ); 65 | if (!passwordIsMatch) 66 | throw new UnauthorizedException("invalid credentials"); 67 | 68 | return this.jwtSignUserId(userExist.id); 69 | } catch (error) { 70 | throw error; 71 | } 72 | } 73 | 74 | private jwtSignUserId(userId: number): string { 75 | return this.jwtService.sign( 76 | { userId }, 77 | { 78 | secret: this.configService.get("JWT_SECRET"), 79 | } 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/auth/constants/jwt.constant.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { Configs } from "../../../configuration"; 3 | 4 | const jwtConstant = { 5 | getSecret: (configService: ConfigService): string => 6 | configService.get("JWT_SECRET"), 7 | signOptions: { 8 | expiresIn: "1d", 9 | }, 10 | }; 11 | 12 | export default jwtConstant; 13 | -------------------------------------------------------------------------------- /src/modules/auth/dtos/signin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; 3 | //* DTO (Data Transfer Object) 4 | export class SignInDto { 5 | 6 | 7 | @ApiProperty({ 8 | description: 'The username of the user', 9 | required: true, 10 | type: String, 11 | example: 'sajjadmrx', 12 | uniqueItems: true, 13 | }) 14 | @IsString() 15 | @IsNotEmpty() 16 | username: string; 17 | 18 | 19 | @ApiProperty({ 20 | description: 'The password of the user', 21 | required: true, 22 | type: String, 23 | example: '@armiow2516fds', 24 | 25 | }) 26 | @IsString() 27 | @IsNotEmpty() 28 | password: string; 29 | 30 | 31 | } -------------------------------------------------------------------------------- /src/modules/auth/dtos/signup.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiResponse } from '@nestjs/swagger'; 2 | import { Contains, IsEmail, IsNotEmpty, IsString } from 'class-validator'; 3 | //* DTO (Data Transfer Object) 4 | export class SignUpDto { 5 | 6 | 7 | @ApiProperty({ 8 | description: 'The username of the user', 9 | required: true, 10 | type: String, 11 | example: 'sajjadmrx', 12 | uniqueItems: true, 13 | }) 14 | @IsString() 15 | @IsNotEmpty() 16 | username: string; 17 | 18 | 19 | @ApiProperty({ 20 | description: 'The email of the user', 21 | required: true, 22 | type: String, 23 | example: 'fake@gmail.com', 24 | uniqueItems: true, 25 | }) 26 | @Contains('@gmail', { 27 | message: 'Email must be a gmail account' 28 | }) 29 | @IsEmail() 30 | @IsString() 31 | @IsNotEmpty() 32 | email: string; 33 | 34 | 35 | 36 | @ApiProperty({ 37 | /// Password 38 | description: 'The password of the user', 39 | required: true, 40 | type: String, 41 | example: '@armiow2516fds', 42 | }) 43 | @IsString() 44 | @IsNotEmpty() 45 | password: string; 46 | 47 | 48 | } -------------------------------------------------------------------------------- /src/modules/auth/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { ExtractJwt, Strategy } from "passport-jwt"; 4 | import { UsersRepository } from "src/modules/users/users.repository"; 5 | 6 | import jwtConstant from "../constants/jwt.constant"; 7 | import { ConfigService } from "@nestjs/config"; 8 | import { Configs } from "../../../configuration"; 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy, "jwt") { 11 | constructor( 12 | private userRepository: UsersRepository, 13 | private configService: ConfigService 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 17 | ignoreExpiration: false, 18 | secretOrKey: configService.get("JWT_SECRET"), 19 | }); 20 | } 21 | 22 | async validate(payload: any) { 23 | const user = await this.userRepository.findById(payload.userId); 24 | if (!user) throw new UnauthorizedException(); 25 | 26 | return user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/categories/categories.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | UseGuards, 11 | UseInterceptors, 12 | } from "@nestjs/common"; 13 | import { 14 | ApiBearerAuth, 15 | ApiOkResponse, 16 | ApiOperation, 17 | ApiParam, 18 | ApiTags, 19 | } from "@nestjs/swagger"; 20 | import CheckRoleGuard from "src/shared/guards/check-roles.guard"; 21 | import { CategoriesService } from "./categories.service"; 22 | import { CreateCategoryDto } from "./dtos/create.dto"; 23 | import { updateCategoryDto } from "./dtos/update.dto"; 24 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 25 | import { authGuard } from "../../shared/guards/auth.guard"; 26 | 27 | @ApiTags("Categories") 28 | @UseInterceptors(ResponseInterceptor) 29 | @Controller("/categories") 30 | export class CategoriesController { 31 | constructor(private categoriesService: CategoriesService) {} 32 | 33 | @ApiOperation({ 34 | summary: "get Categories", 35 | }) 36 | @ApiOkResponse() 37 | @Get("/") 38 | async getCategories() { 39 | return this.categoriesService.getAll(); 40 | } 41 | 42 | @ApiOperation({ summary: "get category by id" }) 43 | @ApiOkResponse() 44 | @Get("/:id") 45 | async getCategoryById(@Param("id", ParseIntPipe) id: number) { 46 | return this.categoriesService.getById(id); 47 | } 48 | 49 | @ApiOperation({ summary: "get category by slug" }) 50 | @ApiOkResponse() 51 | @Get("/s/:slug") 52 | async getCategoryBySlug(@Param("slug") slug: string) { 53 | return this.categoriesService.getBySlug(slug); 54 | } 55 | 56 | @ApiOperation({ 57 | summary: "create a category", 58 | description: `Required Permission: 'ADMIN'`, 59 | }) 60 | @ApiBearerAuth() 61 | @Post("/") 62 | @UseGuards(CheckRoleGuard(["ADMIN"])) 63 | @UseGuards(authGuard(false)) 64 | async createCategory(@Body() createDto: CreateCategoryDto) { 65 | return this.categoriesService.create(createDto); 66 | } 67 | 68 | @ApiOperation({ 69 | summary: "update a category by Id", 70 | description: `Required Permission: 'ADMIN'`, 71 | }) 72 | @ApiBearerAuth() 73 | @Patch("/:id") 74 | @UseGuards(CheckRoleGuard(["ADMIN"])) 75 | @UseGuards(authGuard(false)) 76 | async updateCategory( 77 | @Body() item: updateCategoryDto, 78 | @Param("id", ParseIntPipe) id: number 79 | ) { 80 | return this.categoriesService.update(id, item); 81 | } 82 | 83 | @ApiOperation({ 84 | summary: "delete a category by Id", 85 | description: `Required Permission: 'ADMIN'`, 86 | }) 87 | @ApiBearerAuth() 88 | @Delete("/:id") 89 | @UseGuards(CheckRoleGuard(["ADMIN"])) 90 | @UseGuards(authGuard(false)) 91 | @ApiParam({ name: "id" }) 92 | async deleteCategory(@Param("id", ParseIntPipe) id: number) { 93 | return this.categoriesService.delete(id); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/modules/categories/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CategoriesController } from "./categories.controller"; 3 | import { CategoriesRepository } from "./categories.repository"; 4 | import { CategoriesService } from "./categories.service"; 5 | 6 | @Module({ 7 | controllers: [CategoriesController], 8 | providers: [CategoriesService, CategoriesRepository], 9 | exports: [CategoriesRepository] 10 | }) 11 | export class CategoriesModule { } -------------------------------------------------------------------------------- /src/modules/categories/categories.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { 3 | Category, 4 | CategoryCreateInput, 5 | CategoryUpdateInput, 6 | } from "src/shared/interfaces/categories.interface"; 7 | import { PrismaService } from "../prisma/prisma.service"; 8 | 9 | @Injectable() 10 | export class CategoriesRepository { 11 | constructor(private prisma: PrismaService) {} 12 | 13 | async find() { 14 | return this.prisma.category.findMany(); 15 | } 16 | 17 | async findById(id: number) { 18 | return this.prisma.category.findUnique({ 19 | where: { 20 | id: id, 21 | }, 22 | }); 23 | } 24 | 25 | async findBySlug(slug: string): Promise { 26 | return this.prisma.category.findUnique({ 27 | where: { 28 | slug: slug, 29 | }, 30 | }); 31 | } 32 | 33 | async create(input: CategoryCreateInput): Promise { 34 | return this.prisma.category.create({ 35 | data: { 36 | name: input.name, 37 | slug: input.slug, 38 | }, 39 | }); 40 | } 41 | 42 | async update(id: number, data: CategoryUpdateInput): Promise { 43 | return this.prisma.category.update({ 44 | where: { 45 | id: id, 46 | }, 47 | data: data, 48 | }); 49 | } 50 | 51 | async delete(id: number): Promise { 52 | return this.prisma.category.delete({ 53 | where: { 54 | id: id, 55 | }, 56 | }); 57 | } 58 | 59 | hasExistWithIds(ids: number[]): Promise { 60 | return new Promise((resolve, reject) => { 61 | this.prisma.category 62 | .findMany({ 63 | where: { 64 | id: { 65 | in: ids, 66 | }, 67 | }, 68 | }) 69 | .then((categories: Category[]) => { 70 | resolve(categories.length === ids.length); 71 | }) 72 | .catch((error) => { 73 | reject(error); 74 | }); 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/categories/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from "@nestjs/common"; 2 | import { getResponseMessage } from "src/shared/constants/messages.constant"; 3 | import { CategoryCreateInput } from "src/shared/interfaces/categories.interface"; 4 | import { CategoriesRepository } from "./categories.repository"; 5 | import { CreateCategoryDto } from "./dtos/create.dto"; 6 | import { updateCategoryDto } from "./dtos/update.dto"; 7 | 8 | @Injectable() 9 | export class CategoriesService { 10 | constructor(private categoriesRepository: CategoriesRepository) {} 11 | 12 | async getAll() { 13 | try { 14 | return await this.categoriesRepository.find(); 15 | } catch (error) { 16 | throw error; 17 | } 18 | } 19 | 20 | async getById(id: number) { 21 | try { 22 | const category = await this.categoriesRepository.findById(id); 23 | if (!category) throw new BadRequestException("Category not found"); 24 | 25 | return category; 26 | } catch (error) { 27 | throw error; 28 | } 29 | } 30 | 31 | async getBySlug(slug: string) { 32 | try { 33 | const category = await this.categoriesRepository.findBySlug(slug); 34 | if (!category) throw new BadRequestException("Category not found"); 35 | 36 | return category; 37 | } catch (error) { 38 | throw error; 39 | } 40 | } 41 | 42 | async create(createDto: CreateCategoryDto) { 43 | try { 44 | let input: CategoryCreateInput = { 45 | name: createDto.name, 46 | slug: createDto.slug, 47 | //parentId: 1 48 | }; 49 | 50 | const hasExist = await this.categoriesRepository.findBySlug(input.slug); 51 | if (hasExist) 52 | throw new BadRequestException(getResponseMessage("CATEGORY_EXIST")); 53 | 54 | const created = await this.categoriesRepository.create(input); 55 | 56 | return created; 57 | } catch (error) { 58 | throw error; 59 | } 60 | } 61 | 62 | async update(id: number, item: updateCategoryDto) { 63 | try { 64 | const exist = await this.categoriesRepository.findBySlug(item.slug); 65 | if (exist && exist.id !== id) 66 | throw new BadRequestException(getResponseMessage("CATEGORY_EXIST")); 67 | 68 | const updated = await this.categoriesRepository.update(id, item); 69 | return updated; 70 | } catch (error) { 71 | throw error; 72 | } 73 | } 74 | 75 | async delete(id: number) { 76 | try { 77 | const category = await this.categoriesRepository.findById(id); 78 | if (!category) throw new BadRequestException("Category not found"); 79 | 80 | await this.categoriesRepository.delete(category.id); 81 | return {}; 82 | } catch (error) { 83 | throw error; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/modules/categories/dtos/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsNumber, IsString } from "class-validator"; 3 | 4 | export class CreateCategoryDto { 5 | 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | @ApiProperty({ type: String, example: 'Category name', required: true }) 10 | name: string; 11 | 12 | @IsString() 13 | @IsNotEmpty() 14 | @ApiProperty({ type: String, example: 'animals', required: true }) 15 | slug: string; 16 | 17 | 18 | // @IsNumber() 19 | // @IsNotEmpty() 20 | // @ApiProperty({ type: Number, example: 1, required: false }) 21 | // parentId: number; 22 | 23 | 24 | 25 | } -------------------------------------------------------------------------------- /src/modules/categories/dtos/update.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateCategoryDto } from "./create.dto"; 2 | 3 | export class updateCategoryDto extends CreateCategoryDto { } -------------------------------------------------------------------------------- /src/modules/comments/__test__/comments.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommentsService } from "../comments.service"; 2 | import { CommentsRepository } from "../comments.repository"; 3 | import { PostRepository } from "../../post/post.repository"; 4 | import { BadRequestException, ForbiddenException } from "@nestjs/common"; 5 | import { getResponseMessage } from "../../../shared/constants/messages.constant"; 6 | import { Post } from "../../../shared/interfaces/post.interface"; 7 | import { 8 | Comment, 9 | CommentWithChilds, 10 | } from "../../../shared/interfaces/comment.interface"; 11 | 12 | let post: Post = { 13 | id: 1, 14 | cover: "my-cover.png", 15 | authorId: 1, 16 | tags: "tag1,tag2", 17 | content: "my content", 18 | title: "title", 19 | published: true, 20 | createdAt: new Date(), 21 | updatedAt: new Date(), 22 | }; 23 | 24 | describe("CommentsService", function () { 25 | let comment: Comment; 26 | 27 | let commentsService: CommentsService; 28 | let commentsRepository: CommentsRepository; 29 | let postRepository: PostRepository; 30 | 31 | beforeEach(() => { 32 | jest.clearAllMocks(); 33 | comment = { 34 | id: 1, 35 | authorId: 2, 36 | updatedAt: new Date(), 37 | createdAt: new Date(), 38 | replyId: 1, 39 | text: "test", 40 | postId: 3, 41 | }; 42 | const mockFn = jest.fn() as unknown as any; 43 | commentsRepository = new CommentsRepository(mockFn); 44 | postRepository = new PostRepository(mockFn); 45 | commentsService = new CommentsService(commentsRepository, postRepository); 46 | }); 47 | 48 | it("should Defined", () => { 49 | expect(commentsService).toBeDefined(); 50 | }); 51 | describe("create()", function () { 52 | let commentInput = { postId: 1, text: "Hello", replyId: null }; 53 | it("should throw POST_NOT_EXIST,when not found post", async () => { 54 | jest.spyOn(postRepository, "findById").mockImplementation(() => null); 55 | 56 | await expect( 57 | commentsService.create(commentInput, {} as any) 58 | ).rejects.toEqual( 59 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 60 | ); 61 | }); 62 | it("should throw POST_NOT_EXIST,when post is Private", async () => { 63 | post.published = false; 64 | jest 65 | .spyOn(postRepository, "findById") 66 | .mockImplementation(async () => post); 67 | 68 | await expect( 69 | commentsService.create(commentInput, {} as any) 70 | ).rejects.toEqual( 71 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 72 | ); 73 | }); 74 | it("should throw REPLY_COMMENT_NOT_FOUND,when comment not found", async () => { 75 | post.published = true; 76 | jest 77 | .spyOn(postRepository, "findById") 78 | .mockImplementation(async () => post); 79 | 80 | jest 81 | .spyOn(commentsRepository, "getById") 82 | .mockImplementation(async () => null); 83 | commentInput.replyId = 1; 84 | await expect( 85 | commentsService.create(commentInput, {} as any) 86 | ).rejects.toEqual( 87 | new BadRequestException(getResponseMessage("REPLY_COMMENT_NOT_FOUND")) 88 | ); 89 | }); 90 | it("should not called getById, when replyId is Null", async () => { 91 | jest 92 | .spyOn(postRepository, "findById") 93 | .mockImplementation(async () => post); 94 | jest.spyOn(commentsRepository, "getById").mockImplementation(); 95 | commentInput.replyId = null; 96 | await expect( 97 | commentsService.create(commentInput, {} as any) 98 | ).rejects.toThrow(); 99 | expect(commentsRepository.getById).not.toBeCalled(); 100 | }); 101 | it("should handle database error", async () => { 102 | jest.spyOn(commentsRepository, "create").mockImplementation(() => { 103 | throw new Error("DB TimeOut"); 104 | }); 105 | jest 106 | .spyOn(postRepository, "findById") 107 | .mockImplementation(async () => post); 108 | await expect( 109 | commentsService.create(commentInput, { id: 1 } as any) 110 | ).rejects.toThrow(); 111 | }); 112 | it("should create a comment", () => { 113 | jest 114 | .spyOn(postRepository, "findById") 115 | .mockImplementation(async () => post); 116 | commentInput.replyId = null; 117 | comment.replyId = null; 118 | jest 119 | .spyOn(commentsRepository, "create") 120 | .mockImplementation(async () => comment); 121 | expect( 122 | commentsService.create(commentInput, { id: comment.authorId } as any) 123 | ).resolves.toBe(comment.id); 124 | }); 125 | }); 126 | 127 | describe("delete()", function () { 128 | it("should throw NOT_FOUND,when comment not found", async function () { 129 | jest 130 | .spyOn(commentsRepository, "getById") 131 | .mockImplementation(async () => null); 132 | 133 | await expect( 134 | commentsService.delete(comment.id, {} as any) 135 | ).rejects.toThrow( 136 | new BadRequestException(getResponseMessage("NOT_FOUND")) 137 | ); 138 | }); 139 | it("should throw PERMISSION_DENIED,when userId not-equal authorId", async () => { 140 | jest 141 | .spyOn(commentsRepository, "getById") 142 | .mockImplementation( 143 | async () => comment as unknown as CommentWithChilds 144 | ); 145 | await expect( 146 | commentsService.delete(comment.id, { id: 3, role: ["USER"] } as any) 147 | ).rejects.toThrow(new ForbiddenException("PERMISSION_DENIED")); 148 | }); 149 | it("should throw PERMISSION_DENIED,when user not Admin", async () => { 150 | jest 151 | .spyOn(commentsRepository, "getById") 152 | .mockImplementation( 153 | async () => comment as unknown as CommentWithChilds 154 | ); 155 | await expect( 156 | commentsService.delete(comment.id, { id: 9999, role: ["USER"] } as any) 157 | ).rejects.toThrow(new ForbiddenException("PERMISSION_DENIED")); 158 | }); 159 | it("should called deleteOne & delete a comment", async () => { 160 | const finallyComment: CommentWithChilds = 161 | comment && ({ childs: [] } as unknown as CommentWithChilds); 162 | jest 163 | .spyOn(commentsRepository, "getById") 164 | .mockImplementation(async () => finallyComment); 165 | jest.spyOn(commentsRepository, "deleteOne").mockImplementation(); 166 | 167 | await expect( 168 | commentsService.delete(comment.id, { role: ["ADMIN"], id: 1 } as any) 169 | ).resolves.toEqual({}); 170 | 171 | expect(commentsRepository.deleteOne).toBeCalled(); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/modules/comments/comments.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Post, 8 | Query, 9 | UseGuards, 10 | UseInterceptors, 11 | } from "@nestjs/common"; 12 | import { 13 | ApiBearerAuth, 14 | ApiOperation, 15 | ApiQuery, 16 | ApiTags, 17 | } from "@nestjs/swagger"; 18 | import { CommentCreateDto } from "./dtos/create.dto"; 19 | import { CommentsService } from "./comments.service"; 20 | import { getUser } from "../../shared/decorators/req-user.decorator"; 21 | import { User } from "../../shared/interfaces/user.interface"; 22 | import CheckRoleGuard from "../../shared/guards/check-roles.guard"; 23 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 24 | import { QueryDto } from "./dtos/query.dto"; 25 | import { authGuard } from "../../shared/guards/auth.guard"; 26 | 27 | @ApiTags("Comment") 28 | @UseInterceptors(ResponseInterceptor) 29 | @Controller("comments") 30 | export class CommentsController { 31 | constructor(private commentsService: CommentsService) {} 32 | 33 | @ApiOperation({ summary: "get comments" }) 34 | @ApiQuery({ 35 | name: "postId", 36 | example: 1, 37 | type: String, 38 | required: false, 39 | description: `get comments on a post by Post Id. 40 | If you are an admin, you can send empty for get All Comments`, 41 | }) 42 | @ApiQuery({ 43 | name: "limit", 44 | example: 10, 45 | type: String, 46 | required: false, 47 | }) 48 | @ApiQuery({ 49 | name: "page", 50 | example: 1, 51 | type: String, 52 | required: false, 53 | }) 54 | // @ApiBearerAuth() 55 | @UseGuards(authGuard(true)) 56 | @Get("") 57 | getAll(@Query() query: QueryDto, @getUser() user: User | null) { 58 | return this.commentsService.getAll(query, user); 59 | } 60 | 61 | @ApiOperation({ 62 | summary: "create a comment", 63 | description: "only Users", 64 | }) 65 | @ApiBearerAuth() 66 | @UseGuards(authGuard(false)) 67 | @Post() 68 | async create(@Body() data: CommentCreateDto, @getUser() user: User) { 69 | return this.commentsService.create(data, user); 70 | } 71 | 72 | @ApiOperation({ 73 | summary: "delete comment by commentId", 74 | }) 75 | @ApiBearerAuth() 76 | @UseGuards(CheckRoleGuard(["ADMIN", "USER", "MANAGE_COMMENTS"])) //ADMIN OR USER(self comment) 77 | @UseGuards(authGuard(false)) 78 | @Delete(":id") 79 | async delete(@Param("id") commentId: string, @getUser() user: User) { 80 | return this.commentsService.delete(Number(commentId), user); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from "@nestjs/common"; 2 | import { CommentsRepository } from "./comments.repository"; 3 | import { CommentsController } from "./comments.controller"; 4 | import { CommentsService } from "./comments.service"; 5 | import { PostModule } from "../post/post.module"; 6 | 7 | const providersAndExports = [CommentsRepository]; 8 | @Module({ 9 | imports: [forwardRef(() => PostModule)], 10 | controllers: [CommentsController], 11 | providers: [...providersAndExports, CommentsService], 12 | exports: [...providersAndExports], 13 | }) 14 | export class CommentsModule {} 15 | -------------------------------------------------------------------------------- /src/modules/comments/comments.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaService } from "../prisma/prisma.service"; 3 | import { 4 | Comment, 5 | CommentCreateInput, 6 | CommentWithChilds, 7 | CommentWithRelation, 8 | } from "../../shared/interfaces/comment.interface"; 9 | import { BatchPayload } from "../../shared/interfaces/repository.interface"; 10 | 11 | @Injectable() 12 | export class CommentsRepository { 13 | constructor(private db: PrismaService) {} 14 | 15 | async create(input: CommentCreateInput) { 16 | return this.db.comment.create({ data: input }); 17 | } 18 | 19 | async getById(id: number): Promise { 20 | return this.db.comment.findUnique({ 21 | where: { id }, 22 | include: { childs: true }, 23 | }); 24 | } 25 | 26 | async deleteOne(id: number): Promise { 27 | return this.db.comment.delete({ 28 | where: { 29 | id, 30 | }, 31 | }); 32 | } 33 | 34 | async deleteCommentsByPostId(postId: number): Promise { 35 | return this.db.comment.deleteMany({ 36 | where: { postId }, 37 | }); 38 | } 39 | 40 | async findByPostId( 41 | postId: number, 42 | page: number, 43 | limit: number 44 | ): Promise { 45 | return this.db.comment.findMany({ 46 | where: { 47 | postId: postId, 48 | }, 49 | include: { 50 | childs: true, 51 | author: { 52 | select: { 53 | username: true, 54 | id: true, 55 | }, 56 | }, 57 | }, 58 | take: limit, 59 | skip: (page - 1) * limit, 60 | }); 61 | } 62 | 63 | async find(page: number, limit: number): Promise { 64 | return this.db.comment.findMany({ 65 | take: limit, 66 | skip: (page - 1) * limit, 67 | include: { 68 | childs: true, 69 | author: { 70 | select: { 71 | username: true, 72 | id: true, 73 | }, 74 | }, 75 | }, 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/modules/comments/comments.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForbiddenException, 3 | Injectable, 4 | NotFoundException, 5 | } from "@nestjs/common"; 6 | import { CommentsRepository } from "./comments.repository"; 7 | import { User } from "../../shared/interfaces/user.interface"; 8 | import { CommentCreateDto } from "./dtos/create.dto"; 9 | import { getResponseMessage } from "../../shared/constants/messages.constant"; 10 | import { PostRepository } from "../post/post.repository"; 11 | import { 12 | CommentWithChilds, 13 | CommentWithRelation, 14 | } from "../../shared/interfaces/comment.interface"; 15 | import { Role } from "../../shared/interfaces/role.interface"; 16 | import { QueryDto } from "./dtos/query.dto"; 17 | 18 | @Injectable() 19 | export class CommentsService { 20 | constructor( 21 | private commentsRepository: CommentsRepository, 22 | private postsRepository: PostRepository 23 | ) {} 24 | 25 | async getAll(query: QueryDto, user: User | null) { 26 | try { 27 | const postId: number = Number(query.postId); 28 | const page: number = Number(query.page) || 1; 29 | let limit: number = Number(query.limit) || 10; 30 | let dbQuery: any = {}; 31 | 32 | if (limit > 10) limit = 10; 33 | if (!postId) { 34 | if (user && user.role != "ADMIN") return []; 35 | return await this.commentsRepository.find(page, limit); 36 | } 37 | 38 | const comments: CommentWithRelation[] = 39 | await this.commentsRepository.findByPostId(postId, page, limit); 40 | return comments; 41 | } catch (e) { 42 | throw e; 43 | } 44 | } 45 | 46 | async create(data: CommentCreateDto, user: User) { 47 | try { 48 | const postId: number = data.postId; 49 | 50 | const hasPost = await this.postsRepository.findById(postId); 51 | if (!hasPost || !hasPost.published) 52 | throw new NotFoundException(getResponseMessage("POST_NOT_EXIST")); 53 | 54 | const replyId: number | null = data.replyId; 55 | if (replyId) { 56 | const hasComment: CommentWithChilds | null = 57 | await this.commentsRepository.getById(replyId); 58 | 59 | if (!hasComment || hasComment.postId != hasPost.id) 60 | throw new NotFoundException( 61 | getResponseMessage("REPLY_COMMENT_NOT_FOUND") 62 | ); 63 | } 64 | 65 | const comment = await this.commentsRepository.create({ 66 | postId, 67 | replyId, 68 | text: data.text, 69 | authorId: user.id, 70 | }); 71 | 72 | return comment.id; 73 | } catch (e) { 74 | throw e; 75 | } 76 | } 77 | 78 | async delete(commentId: number, user: User) { 79 | try { 80 | const comment: CommentWithChilds | null = 81 | await this.commentsRepository.getById(commentId); 82 | 83 | if (!comment) 84 | throw new NotFoundException(getResponseMessage("NOT_FOUND")); 85 | 86 | if (comment.authorId != user.id && !user.role.includes(Role.ADMIN)) { 87 | throw new ForbiddenException("PERMISSION_DENIED"); 88 | } 89 | 90 | await this.commentsRepository.deleteOne(comment.id); 91 | 92 | return {}; 93 | } catch (e) { 94 | throw e; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/modules/comments/dtos/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from "class-validator"; 2 | import { ApiProperty } from "@nestjs/swagger"; 3 | 4 | export class CommentCreateDto { 5 | @ApiProperty({ 6 | type: String, 7 | required: true, 8 | name: "text", 9 | description: "content of comment", 10 | default: "Nice", 11 | }) 12 | @IsString() 13 | text: string; 14 | 15 | @ApiProperty({ 16 | name: "postId", 17 | required: true, 18 | type: Number, 19 | }) 20 | @IsNumber() 21 | postId: number; 22 | 23 | @ApiProperty({ 24 | type: Number, 25 | required: false, 26 | name: "replyId", 27 | description: "reply to a comment", 28 | }) 29 | @IsNumber() 30 | @IsOptional() 31 | replyId?: number; 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/comments/dtos/query.dto.ts: -------------------------------------------------------------------------------- 1 | export class QueryDto { 2 | postId: string | null; 3 | page: string | null; 4 | limit: string | null; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/logging/__test__/loggingService.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoggingService } from "../logging.service"; 2 | import { ConsoleLogger } from "../loggers/console.logger"; 3 | 4 | describe("LoggingService", function () { 5 | describe("Console", function () { 6 | let loggingService: LoggingService; 7 | let consoleLogger: ConsoleLogger; 8 | beforeEach(() => { 9 | consoleLogger = new ConsoleLogger(); 10 | loggingService = new LoggingService(consoleLogger); 11 | }); 12 | it("should Defined", function () { 13 | expect(loggingService).toBeDefined(); 14 | }); 15 | it("should called warn", function () { 16 | jest.spyOn(consoleLogger, "warn").mockImplementation(); 17 | loggingService.warn("Hello"); 18 | expect(consoleLogger.warn).toBeCalledTimes(1); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/modules/logging/constants/dep_keys.constant.ts: -------------------------------------------------------------------------------- 1 | export enum DependencyKey { 2 | MESSAGE_LOGGER = "messageLogger", 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/logging/loggers/console.logger.ts: -------------------------------------------------------------------------------- 1 | import { MessageLogger } from "../../../shared/interfaces/messageLogger.interface"; 2 | import chalk from "chalk"; 3 | export class ConsoleLogger implements MessageLogger { 4 | error(message: string, stack?: string): void { 5 | console.log(chalk.red`[ERROR] `, message); 6 | } 7 | 8 | log(message: string): void { 9 | console.log(chalk.gray`[LOG] `, message); 10 | } 11 | 12 | warn(message: string, stack?: string): void { 13 | console.log(chalk.yellow`[WARN] `, message); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/logging/logging.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module } from "@nestjs/common"; 2 | import { LoggingService } from "./logging.service"; 3 | import { MessageLogger } from "../../shared/interfaces/messageLogger.interface"; 4 | import { DependencyKey } from "./constants/dep_keys.constant"; 5 | 6 | @Global() 7 | @Module({}) 8 | export class LoggingModule { 9 | static register(messageLogger: MessageLogger): DynamicModule { 10 | return { 11 | module: LoggingModule, 12 | providers: [ 13 | { 14 | provide: DependencyKey.MESSAGE_LOGGER, 15 | useValue: messageLogger, 16 | }, 17 | LoggingService, 18 | ], 19 | exports: [LoggingService], 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/logging/logging.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, LoggerService } from "@nestjs/common"; 2 | import { MessageLogger } from "../../shared/interfaces/messageLogger.interface"; 3 | import { DependencyKey } from "./constants/dep_keys.constant"; 4 | 5 | @Injectable() 6 | export class LoggingService implements LoggerService { 7 | constructor( 8 | @Inject(DependencyKey.MESSAGE_LOGGER) 9 | private messageLogger: MessageLogger 10 | ) {} 11 | error(message: any, ...optionalParams: any[]): any { 12 | this.messageLogger.error(message); 13 | } 14 | 15 | log(message: any, ...optionalParams: any[]): any { 16 | this.messageLogger.log(message); 17 | } 18 | 19 | warn(message: any, ...optionalParams: any[]): any { 20 | this.messageLogger.warn(message); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from "@nestjs/common"; 2 | import { MailerModule } from "@nestjs-modules/mailer"; 3 | import { EjsAdapter } from "@nestjs-modules/mailer/dist/adapters/ejs.adapter"; 4 | import { MailService } from "./mail.service"; 5 | import path, { join } from "path"; 6 | import { ConfigService } from "@nestjs/config"; 7 | 8 | const providersAndExports = [MailService]; 9 | @Global() 10 | @Module({ 11 | imports: [ 12 | MailerModule.forRootAsync({ 13 | useFactory: (config: ConfigService) => ({ 14 | transport: { 15 | host: config.get("EMAIL_HOST"), 16 | port: config.get("EMAIL_PORT"), 17 | auth: { 18 | user: config.get("EMAIL_USER"), 19 | pass: config.get("EMAIL_PASS"), 20 | }, 21 | }, 22 | template: { 23 | dir: join(path.resolve(), "templates"), 24 | adapter: new EjsAdapter(), 25 | }, 26 | }), 27 | inject: [ConfigService], 28 | }), 29 | ], 30 | controllers: [], 31 | providers: [...providersAndExports], 32 | exports: [...providersAndExports], 33 | }) 34 | export class MailModule {} 35 | -------------------------------------------------------------------------------- /src/modules/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { MailerService } from "@nestjs-modules/mailer"; 3 | import { User } from "@prisma/client"; 4 | import { MailConstant } from "../../shared/constants/mail.constant"; 5 | 6 | @Injectable() 7 | export class MailService { 8 | constructor(private emailService: MailerService) {} 9 | 10 | async sendWelcome(user: User): Promise { 11 | try { 12 | const brandName: string = "x"; 13 | await this.emailService.sendMail({ 14 | from: MailConstant.FROM, 15 | to: user.email, 16 | subject: `welcome to ${brandName} blog!`, 17 | context: { 18 | username: user.username, 19 | }, 20 | template: "welcome.ejs", 21 | }); 22 | } catch (e) { 23 | throw e; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/post/__test__/post.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostService } from "../post.service"; 2 | import { PostRepository } from "../post.repository"; 3 | import { Post } from "../../../shared/interfaces/post.interface"; 4 | import { BadRequestException } from "@nestjs/common"; 5 | import { getResponseMessage } from "../../../shared/constants/messages.constant"; 6 | import { fileHasExist } from "../../../shared/utils/fileValidator.util"; 7 | import * as fileValidator from "../../../shared/utils/fileValidator.util"; 8 | import { CategoriesRepository } from "../../categories/categories.repository"; 9 | import { CreatePostDto } from "../dtos/createPost.dto"; 10 | import { LoggingService } from "../../logging/logging.service"; 11 | import { ConsoleLogger } from "../../logging/loggers/console.logger"; 12 | 13 | let post: Post = { 14 | id: 1, 15 | cover: "my-cover.png", 16 | authorId: 1, 17 | tags: "tag1,tag2", 18 | content: "my content", 19 | title: "title", 20 | published: true, 21 | createdAt: new Date(), 22 | updatedAt: new Date(), 23 | }; 24 | 25 | let getPostInput = (): CreatePostDto => { 26 | return { 27 | content: "test", 28 | cover: "my-cover.png", 29 | tags: ["AAA"], 30 | published: false, 31 | title: "test", 32 | categories: [1, 2], 33 | }; 34 | }; 35 | 36 | describe("PostService", function () { 37 | let postService: PostService; 38 | let postRepository: PostRepository; 39 | let categoriesRepository: CategoriesRepository; 40 | let queueDeleteFile; 41 | let logger: LoggingService; 42 | beforeEach(() => { 43 | jest.clearAllMocks(); 44 | postRepository = new PostRepository(jest.fn() as unknown as any); 45 | categoriesRepository = new CategoriesRepository( 46 | jest.fn() as unknown as any 47 | ); 48 | queueDeleteFile = { 49 | add: jest.fn(), 50 | }; 51 | logger = new LoggingService(new ConsoleLogger()); 52 | postService = new PostService( 53 | postRepository, 54 | categoriesRepository, 55 | queueDeleteFile, 56 | logger 57 | ); 58 | jest.spyOn(logger, "error").mockImplementation(); 59 | }); 60 | 61 | it("should Defined", () => { 62 | expect(postService).toBeDefined(); 63 | }); 64 | 65 | describe("singlePost()", function () { 66 | it("should throw POST_NOT_EXIST", async () => { 67 | jest.spyOn(postRepository, "findById").mockImplementation(() => null); 68 | 69 | await expect(postService.singlePost(post.id)).rejects.toEqual( 70 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 71 | ); 72 | }); 73 | it("should throw POST_NOT_EXIST,when post is private", async () => { 74 | post.published = false; 75 | jest 76 | .spyOn(postRepository, "findById") 77 | .mockImplementation(async () => post); 78 | 79 | await expect(postService.singlePost(post.id)).rejects.toEqual( 80 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 81 | ); 82 | }); 83 | it("should return post", async () => { 84 | post.published = true; 85 | jest 86 | .spyOn(postRepository, "findById") 87 | .mockImplementation(async () => post); 88 | await expect(postService.singlePost(post.id)).resolves.toEqual(post); 89 | }); 90 | it("should handle database error", async () => { 91 | jest.spyOn(postRepository, "findById").mockImplementation(() => { 92 | throw new Error("request time out!"); 93 | }); 94 | await expect(postService.singlePost(post.id)).rejects.toThrowError(); 95 | }); 96 | }); 97 | 98 | describe("create()", function () { 99 | describe("cover", function () { 100 | it("should throw FILE_NOT_EXIST,when set unknown cover image", async () => { 101 | jest 102 | .spyOn(fileValidator, "fileHasExist") 103 | .mockImplementation(async () => false); 104 | 105 | await expect(postService.create(1, getPostInput())).rejects.toEqual( 106 | new BadRequestException("FILE_NOT_EXIST") 107 | ); 108 | }); 109 | it("should ignore file check, when file field is null", async () => { 110 | const postInput = getPostInput(); 111 | jest 112 | .spyOn(fileValidator, "fileHasExist") 113 | .mockImplementation(async () => true); 114 | 115 | postInput.cover = null; 116 | 117 | await expect(postService.create(1, postInput)).rejects.toThrowError(); 118 | 119 | expect(fileValidator.fileHasExist).not.toBeCalled(); 120 | }); 121 | }); 122 | 123 | describe("categories", function () { 124 | it("should throw CATEGORIES_NOT_EXIST,when set unknown categories", async () => { 125 | const postInput = getPostInput(); 126 | postInput.cover = null; 127 | jest 128 | .spyOn(categoriesRepository, "hasExistWithIds") 129 | .mockImplementation(async () => false); 130 | await expect(postService.create(1, postInput)).rejects.toEqual( 131 | new BadRequestException(getResponseMessage("CATEGORIES_NOT_EXIST")) 132 | ); 133 | }); 134 | }); 135 | 136 | describe("tags", function () { 137 | it("should throw TAGS_INVALID, when tags not array", async () => { 138 | const postInput = getPostInput(); 139 | postInput.tags = "test" as unknown as any; 140 | postInput.cover = null; 141 | jest 142 | .spyOn(categoriesRepository, "hasExistWithIds") 143 | .mockImplementation(async (args) => true); 144 | 145 | await expect(postService.create(1, postInput)).rejects.toEqual( 146 | new BadRequestException(getResponseMessage("TAGS_INVALID")) 147 | ); 148 | }); 149 | }); 150 | 151 | it("should return created post", async () => { 152 | const postInput = getPostInput(); 153 | jest 154 | .spyOn(fileValidator, "fileHasExist") 155 | .mockImplementation(async () => true); 156 | 157 | jest 158 | .spyOn(categoriesRepository, "hasExistWithIds") 159 | .mockImplementation(async () => true); 160 | 161 | jest.spyOn(postRepository, "create").mockImplementation(async () => post); 162 | 163 | await expect(postService.create(1, postInput)).resolves.toEqual(post); 164 | }); 165 | }); 166 | 167 | describe("update()", function () { 168 | it("should throw POST_NOT_EXIST,when not found post", async () => { 169 | const postInput = getPostInput(); 170 | jest 171 | .spyOn(postRepository, "findById") 172 | .mockImplementation(async () => null); 173 | 174 | await expect(postService.update(1, 2, postInput)).rejects.toEqual( 175 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 176 | ); 177 | }); 178 | }); 179 | describe("delete()", function () { 180 | it("should throw POST_NOT_EXIST,when not found post", async () => { 181 | jest 182 | .spyOn(postRepository, "findById") 183 | .mockImplementation(async () => null); 184 | await expect(postService.delete(1, 2)).rejects.toEqual( 185 | new BadRequestException(getResponseMessage("POST_NOT_EXIST")) 186 | ); 187 | }); 188 | it("should return postId,when successfully delete post", async () => { 189 | jest 190 | .spyOn(postRepository, "findById") 191 | .mockImplementation(async () => post); 192 | jest.spyOn(postRepository, "delete").mockImplementation(async () => post); 193 | 194 | await expect(postService.delete(1, post.id)).resolves.toBe(post.id); 195 | }); 196 | it("should call deleteFileQueue after delete post", async () => { 197 | jest 198 | .spyOn(postRepository, "findById") 199 | .mockImplementation(async () => post); 200 | jest.spyOn(postRepository, "delete").mockImplementation(async () => post); 201 | 202 | await postService.delete(1, post.id); 203 | expect(queueDeleteFile.add).toBeCalledTimes(1); 204 | }); 205 | }); 206 | describe("getPublicPosts()", function () { 207 | const posts: Post[] = [ 208 | post, 209 | { 210 | id: 2, 211 | tags: "a,b,c", 212 | cover: "googlelogo.png", 213 | published: true, 214 | createdAt: new Date(), 215 | updatedAt: new Date(), 216 | title: "Hello World!", 217 | content: "just Hello World! 🤞", 218 | authorId: 3, 219 | }, 220 | { 221 | id: 3, 222 | tags: "a,b,c", 223 | cover: "googlelogo.png", 224 | published: true, 225 | createdAt: new Date(), 226 | updatedAt: new Date(), 227 | title: "Hello World!", 228 | content: "just Hello World! 🤞", 229 | authorId: 3, 230 | }, 231 | ]; 232 | it("should return posts by matched title", async () => { 233 | const query = { 234 | content: "", 235 | title: "hello", 236 | limit: 10, 237 | page: 1, 238 | }; 239 | const finallyPosts = posts.filter( 240 | (post: Post) => 241 | post.title.toLowerCase().match(query.title) && post.published == true 242 | ); 243 | 244 | jest.spyOn(postRepository, "findPublic").mockImplementation(async () => { 245 | return finallyPosts; 246 | }); 247 | 248 | jest 249 | .spyOn(postRepository, "countPublished") 250 | .mockImplementation(async () => { 251 | return posts.filter((post) => post.published).length; 252 | }); 253 | const result = await postService.getPublicPosts(query); 254 | expect(result.posts).toHaveLength(finallyPosts.length); 255 | }); 256 | it("should return posts by matched content", async () => { 257 | const query = { 258 | content: "just", 259 | title: "hello", 260 | limit: 10, 261 | page: 1, 262 | }; 263 | const finallyPosts = posts.filter( 264 | (post: Post) => 265 | post.content.toLowerCase().match(query.content) && 266 | post.published == true 267 | ); 268 | 269 | jest.spyOn(postRepository, "findPublic").mockImplementation(async () => { 270 | return finallyPosts; 271 | }); 272 | 273 | jest 274 | .spyOn(postRepository, "countPublished") 275 | .mockImplementation(async () => { 276 | return posts.filter((post) => post.published).length; 277 | }); 278 | const result = await postService.getPublicPosts(query); 279 | expect(result.posts).toHaveLength(finallyPosts.length); 280 | }); 281 | it("should return all post", async () => { 282 | const query = { 283 | content: "", 284 | title: "", 285 | limit: 10, 286 | page: 1, 287 | }; 288 | const finallyPosts = posts.filter((post: Post) => post.published == true); 289 | 290 | jest.spyOn(postRepository, "findPublic").mockImplementation(async () => { 291 | return finallyPosts; 292 | }); 293 | 294 | jest 295 | .spyOn(postRepository, "countPublished") 296 | .mockImplementation(async () => { 297 | return finallyPosts.length; 298 | }); 299 | 300 | const result = await postService.getPublicPosts(query); 301 | expect(result.posts).toHaveLength(finallyPosts.length); 302 | expect(result.total).toBe(finallyPosts.length); 303 | }); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /src/modules/post/dtos/createPost.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; 3 | 4 | export class CreatePostDto { 5 | 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | @ApiProperty({ 10 | description: 'Title of the post', 11 | example: 'My first post', 12 | required: true, 13 | }) 14 | title: string; 15 | 16 | @IsString() 17 | @IsNotEmpty() 18 | @ApiProperty({ 19 | description: 'Content of the post', 20 | example: 'This is my first post', 21 | required: true, 22 | }) 23 | content: string; 24 | 25 | 26 | @ApiProperty({ 27 | description: 'Cover image of the post', 28 | example: 'googlelogo_color_272x92dp.png', 29 | required: false, 30 | }) 31 | @IsOptional() 32 | @IsString() 33 | cover: string; 34 | 35 | @ApiProperty({ 36 | description: 'Published of the post', 37 | example: true, 38 | required: true 39 | }) 40 | @IsBoolean() 41 | published: boolean 42 | 43 | 44 | @ApiProperty({ 45 | description: 'category of the post', 46 | example: [1], 47 | required: true, 48 | maxItems: 3, 49 | minItems: 1, 50 | }) 51 | @ArrayMinSize(1) 52 | @ArrayMaxSize(3) 53 | @IsArray() 54 | categories: number[]; 55 | 56 | 57 | @ApiProperty({ 58 | description: 'Tags of the post', 59 | example: ['tag1', 'tag2'], 60 | required: true 61 | }) 62 | tags: string[]; 63 | } -------------------------------------------------------------------------------- /src/modules/post/dtos/search.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from "@nestjs/graphql"; 2 | 3 | @ArgsType() 4 | export class searchPostDto { 5 | @Field({ nullable: true }) 6 | title: string; 7 | @Field({ nullable: true }) 8 | content: string; 9 | page: number; 10 | limit: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/post/dtos/updatePost.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostDto } from "./createPost.dto"; 2 | 3 | export class UpdatePostDto extends CreatePostDto { } -------------------------------------------------------------------------------- /src/modules/post/models/author.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 | import { User } from "../../../shared/interfaces/user.interface"; 3 | 4 | @ObjectType({ 5 | description: "user model", 6 | }) 7 | export class authorModel implements Omit { 8 | @Field((type) => ID) 9 | id: number; 10 | @Field((type) => String) 11 | username: string; 12 | @Field((type) => Date) 13 | createdAt: Date; 14 | @Field((type) => Date) 15 | updatedAt: Date; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/post/models/post.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; 2 | import { Post } from "../../../shared/interfaces/post.interface"; 3 | import { authorModel } from "./author.model"; 4 | 5 | @ObjectType({ 6 | description: "Post Model", 7 | }) 8 | export class PostModel implements Post { 9 | @Field((type) => ID) 10 | id: number; 11 | @Field((type) => String) 12 | title: string; 13 | @Field((type) => String) 14 | content: string; 15 | @Field((type) => Int) 16 | authorId: number; 17 | @Field((type) => Boolean) 18 | published: boolean; 19 | @Field((type) => String) 20 | cover: string; 21 | @Field((type) => Date) 22 | createdAt: Date; 23 | @Field((type) => Date) 24 | updatedAt: Date; 25 | @Field((type) => String) 26 | tags: string; 27 | @Field((type) => authorModel) 28 | author: authorModel; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/post/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | Query, 10 | UseGuards, 11 | UseInterceptors, 12 | } from "@nestjs/common"; 13 | import { 14 | ApiBearerAuth, 15 | ApiOperation, 16 | ApiParam, 17 | ApiQuery, 18 | ApiTags, 19 | } from "@nestjs/swagger"; 20 | 21 | import { getUser } from "src/shared/decorators/req-user.decorator"; 22 | import CheckRoleGuard from "src/shared/guards/check-roles.guard"; 23 | import { CreatePostDto } from "./dtos/createPost.dto"; 24 | import { searchPostDto } from "./dtos/search.dto"; 25 | import { UpdatePostDto } from "./dtos/updatePost.dto"; 26 | 27 | import { PostService } from "./post.service"; 28 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 29 | import { authGuard } from "../../shared/guards/auth.guard"; 30 | import * as process from "process"; 31 | const wit = () => new Promise((res) => setTimeout(res, 3000)); 32 | @ApiTags("Post") 33 | @UseInterceptors(ResponseInterceptor) 34 | @Controller("posts") 35 | export class PostController { 36 | constructor(private postService: PostService) {} 37 | 38 | @ApiOperation({ 39 | summary: "get public Posts", 40 | }) 41 | @ApiQuery({ name: "title", required: false }) 42 | @ApiQuery({ name: "content", required: false }) 43 | @ApiQuery({ name: "page", required: false }) 44 | @ApiQuery({ name: "limit", required: false }) 45 | @Get() 46 | async getPublicPosts(@Query() query: searchPostDto) { 47 | await wit(); 48 | return `Process Id :${process.pid}`; 49 | // return this.postService.getPublicPosts(query); 50 | } 51 | 52 | @ApiOperation({ summary: "get post by Id" }) 53 | @ApiParam({ name: "id", required: true }) 54 | @Get("/:id") 55 | async getPost(@Param("id") id: string) { 56 | return this.postService.singlePost(Number(id)); 57 | } 58 | 59 | @ApiOperation({ 60 | summary: "create post", 61 | description: `Required Permission: 'ADMIN'`, 62 | }) 63 | @ApiBearerAuth() 64 | @Post("/") 65 | @UseGuards(CheckRoleGuard(["ADMIN", "MANAGE_POSTS"])) 66 | @UseGuards(authGuard(false)) 67 | async createPost( 68 | @getUser("id") userId: number, 69 | @Body() createPostDto: CreatePostDto 70 | ) { 71 | return this.postService.create(userId, createPostDto); 72 | } 73 | 74 | @ApiOperation({ 75 | summary: "update post by Id", 76 | description: `Required Permission: 'ADMIN'`, 77 | }) 78 | @ApiBearerAuth() 79 | @Patch(":id") 80 | @UseGuards(CheckRoleGuard(["ADMIN", "MANAGE_POSTS"])) 81 | @UseGuards(authGuard(false)) 82 | async updatePost( 83 | @getUser("id") userId: number, 84 | @Param("id") id: string, 85 | @Body() createPostDto: UpdatePostDto 86 | ) { 87 | return this.postService.update(userId, Number(id), createPostDto); 88 | } 89 | 90 | @ApiOperation({ 91 | summary: "delete post by Id", 92 | description: `Required Permission: 'ADMIN'`, 93 | }) 94 | @Delete(":id") 95 | @ApiBearerAuth() 96 | @UseGuards(CheckRoleGuard(["ADMIN", "MANAGE_POSTS"])) 97 | @UseGuards(authGuard(false)) 98 | async deletePost(@getUser("id") userId: number, @Param("id") id: string) { 99 | return this.postService.delete(userId, Number(id)); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/modules/post/post.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { AuthModule } from "../auth/auth.module"; 3 | import { CategoriesModule } from "../categories/categories.module"; 4 | import { PostController } from "./post.controller"; 5 | import { PostRepository } from "./post.repository"; 6 | import { PostService } from "./post.service"; 7 | import { CommentsModule } from "../comments/comments.module"; 8 | import { PostResolver } from "./post.resolver"; 9 | import { LoggingModule } from "../logging/logging.module"; 10 | import { ConsoleLogger } from "../logging/loggers/console.logger"; 11 | 12 | @Module({ 13 | imports: [ 14 | AuthModule, 15 | CategoriesModule, 16 | CommentsModule, 17 | LoggingModule.register(new ConsoleLogger()), 18 | ], 19 | controllers: [PostController], 20 | providers: [PostService, PostRepository, PostResolver], 21 | exports: [PostRepository], 22 | }) 23 | export class PostModule {} 24 | -------------------------------------------------------------------------------- /src/modules/post/post.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { 3 | Post, 4 | PostCreateInput, 5 | PostUpdateInput, 6 | } from "src/shared/interfaces/post.interface"; 7 | import { PrismaService } from "../prisma/prisma.service"; 8 | 9 | @Injectable() 10 | export class PostRepository { 11 | constructor(private prisma: PrismaService) {} 12 | 13 | async findPublic( 14 | page: number, 15 | limit: number, 16 | query: any = {} 17 | ): Promise { 18 | return this.prisma.post.findMany({ 19 | where: { 20 | ...query, 21 | published: true, 22 | }, 23 | take: limit, 24 | skip: (page - 1) * limit, 25 | orderBy: { 26 | createdAt: "desc", 27 | }, 28 | include: { 29 | author: { 30 | select: { 31 | id: true, 32 | username: true, 33 | createdAt: true, 34 | updatedAt: true, 35 | }, 36 | }, 37 | categories: { 38 | select: { 39 | postId: false, 40 | categoryId: false, 41 | assignedAt: false, 42 | category: { 43 | select: { 44 | id: true, 45 | name: true, 46 | slug: true, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }); 53 | } 54 | 55 | async findById(id: number): Promise { 56 | return this.prisma.post.findUnique({ 57 | where: { 58 | id: id, 59 | }, 60 | include: { 61 | author: { 62 | select: { 63 | id: true, 64 | username: true, 65 | createdAt: true, 66 | role: true, 67 | }, 68 | }, 69 | categories: { 70 | select: { 71 | postId: false, 72 | categoryId: false, 73 | assignedAt: false, 74 | category: { 75 | select: { 76 | id: true, 77 | name: true, 78 | slug: true, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }); 85 | } 86 | 87 | async countPublished(query): Promise { 88 | return this.prisma.post.count({ where: { ...query, published: true } }); 89 | } 90 | 91 | async create(post: PostCreateInput): Promise { 92 | return this.prisma.post.create({ data: post }); 93 | } 94 | 95 | async update(id: number, data: PostUpdateInput): Promise { 96 | return this.prisma.post.update({ 97 | where: { 98 | id: id, 99 | }, 100 | data: data, 101 | }); 102 | } 103 | 104 | async delete(id: number): Promise { 105 | return this.prisma.post.delete({ 106 | where: { 107 | id, 108 | }, 109 | }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/modules/post/post.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Int, Query, Resolver } from "@nestjs/graphql"; 2 | import { PostModel } from "./models/post.model"; 3 | import { PostRepository } from "./post.repository"; 4 | import { searchPostDto } from "./dtos/search.dto"; 5 | import { Post } from "../../shared/interfaces/post.interface"; 6 | 7 | import { LoggingService } from "../logging/logging.service"; 8 | 9 | @Resolver() 10 | export class PostResolver { 11 | constructor( 12 | private postRepository: PostRepository, 13 | private logging: LoggingService 14 | ) {} 15 | 16 | @Query((returns) => [PostModel]) 17 | async all( 18 | @Args("page", { nullable: false }) page: number, 19 | @Args("limit", { nullable: false }) limit: number, 20 | @Args() args: searchPostDto 21 | ) { 22 | try { 23 | const query = { 24 | title: { 25 | contains: args.title, 26 | }, 27 | content: { 28 | contains: args.content, 29 | }, 30 | }; 31 | return this.postRepository.findPublic(page, limit, query); 32 | } catch (error: any) { 33 | this.logging.error(error.message, error.stack); 34 | throw error; 35 | } 36 | } 37 | @Query((returns) => PostModel) 38 | async byId( 39 | @Args("postId", { nullable: false, type: () => Int }) postId: number 40 | ) { 41 | const post: Post = await this.postRepository.findById(postId); 42 | //todo: check auth & user role for access private post 43 | return post; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/post/post.service.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from "bull"; 2 | import { InjectQueue } from "@nestjs/bull"; 3 | import { 4 | BadRequestException, 5 | Injectable, 6 | NotFoundException, 7 | } from "@nestjs/common"; 8 | import { getResponseMessage } from "src/shared/constants/messages.constant"; 9 | import { fileHasExist } from "src/shared/utils/fileValidator.util"; 10 | import { 11 | Post, 12 | PostCreateInput, 13 | PostUpdateInput, 14 | } from "src/shared/interfaces/post.interface"; 15 | import { CategoriesRepository } from "../categories/categories.repository"; 16 | import { CreatePostDto } from "./dtos/createPost.dto"; 17 | import { searchPostDto } from "./dtos/search.dto"; 18 | import { UpdatePostDto } from "./dtos/updatePost.dto"; 19 | import { PostRepository } from "./post.repository"; 20 | import { getCategoriesData } from "./post.utility"; 21 | import { QueuesConstant } from "../../shared/constants/queues.constant"; 22 | import { deleteFileQueue } from "../../shared/interfaces/queues.interface"; 23 | import { LoggingService } from "../logging/logging.service"; 24 | 25 | @Injectable() 26 | export class PostService { 27 | constructor( 28 | private postRepository: PostRepository, 29 | private categoriesRepository: CategoriesRepository, 30 | @InjectQueue(QueuesConstant.DELETE_FILE) 31 | private deleteFileQueue: Queue, 32 | private logger: LoggingService 33 | ) {} 34 | 35 | async getPublicPosts(search: searchPostDto) { 36 | let query: any = {}; 37 | 38 | if (search.title) query.title = { contains: search.title }; 39 | if (search.content) query.content = { contains: search.content }; 40 | 41 | const page: number = Number(search.page) || 1; 42 | let limit: number = Number(search.limit) || 10; 43 | if (limit > 10) limit = 10; 44 | 45 | try { 46 | const posts: Post[] = await this.postRepository.findPublic( 47 | page, 48 | limit, 49 | query 50 | ); 51 | const total = await this.postRepository.countPublished(query); 52 | const pages = Math.ceil(total / limit); 53 | const hasNext = page < pages; 54 | const hasPrev = page > 1; 55 | const nextPage = page + 1; 56 | return { 57 | posts, 58 | total, 59 | pages, 60 | hasNext, 61 | hasPrev, 62 | nextPage, 63 | }; 64 | } catch (error: any) { 65 | this.logger.error(error.message, error.stack); 66 | throw error; 67 | } 68 | } 69 | 70 | async singlePost(id: number) { 71 | try { 72 | const post: Post = await this.postRepository.findById(id); 73 | 74 | if (!post || !post.published) 75 | throw new BadRequestException(getResponseMessage("POST_NOT_EXIST")); 76 | return post; 77 | } catch (error: any) { 78 | this.logger.error(error.message, error.stack); 79 | throw error; 80 | } 81 | } 82 | 83 | async create(userId: number, createPostDto: CreatePostDto) { 84 | try { 85 | if (createPostDto.cover) { 86 | const hasExist: boolean = await fileHasExist( 87 | createPostDto.cover, 88 | "./uploads/posts" 89 | ); 90 | if (!hasExist) { 91 | throw new BadRequestException(getResponseMessage("FILE_NOT_EXIST")); 92 | } 93 | } 94 | 95 | try { 96 | const validate: boolean = 97 | await this.categoriesRepository.hasExistWithIds( 98 | createPostDto.categories 99 | ); 100 | if (!validate) 101 | throw new BadRequestException( 102 | getResponseMessage("CATEGORIES_NOT_EXIST") 103 | ); 104 | } catch (error: any) { 105 | this.logger.error(error.message, error.stack); 106 | throw error; 107 | } 108 | 109 | let tags: string; 110 | try { 111 | if (Array.isArray(createPostDto.tags)) 112 | tags = JSON.stringify(createPostDto.tags); 113 | else throw new BadRequestException(getResponseMessage("TAGS_INVALID")); 114 | } catch (error) { 115 | throw error; 116 | } 117 | 118 | const postInput: PostCreateInput = { 119 | ...createPostDto, 120 | tags: tags, 121 | authorId: userId, 122 | cover: createPostDto.cover || "default.png", 123 | categories: { 124 | create: getCategoriesData(createPostDto.categories), 125 | }, 126 | }; 127 | 128 | const post = await this.postRepository.create(postInput); 129 | 130 | return post; 131 | } catch (error: any) { 132 | this.logger.error(error.message, error.stack); 133 | throw error; 134 | } 135 | } 136 | 137 | async update(userId: number, id: number, updatePostDto: UpdatePostDto) { 138 | try { 139 | const post: Post | null = await this.postRepository.findById(id); 140 | if (!post) 141 | throw new NotFoundException(getResponseMessage("POST_NOT_EXIST")); 142 | 143 | if (updatePostDto.cover) { 144 | const hasExist: boolean = await fileHasExist( 145 | updatePostDto.cover, 146 | "./uploads/posts" 147 | ); 148 | if (!hasExist) { 149 | throw new BadRequestException(getResponseMessage("FILE_NOT_EXIST")); 150 | } 151 | } 152 | 153 | try { 154 | const valiadate = await this.categoriesRepository.hasExistWithIds( 155 | updatePostDto.categories 156 | ); 157 | if (!valiadate) throw new Error("catch"); 158 | } catch (error) { 159 | throw new BadRequestException( 160 | getResponseMessage("CATEGORIES_NOT_EXIST") 161 | ); 162 | } 163 | 164 | let tags: string; 165 | try { 166 | if (Array.isArray(updatePostDto.tags)) 167 | tags = JSON.stringify(updatePostDto.tags); 168 | else throw new Error("catch"); 169 | } catch (error) { 170 | throw new BadRequestException(getResponseMessage("TAGS_INVALID")); 171 | } 172 | const data: PostUpdateInput = { 173 | ...updatePostDto, 174 | tags: JSON.stringify(tags), 175 | authorId: userId, 176 | categories: { 177 | create: getCategoriesData(updatePostDto.categories), 178 | }, 179 | cover: updatePostDto.cover || "default.png", 180 | }; 181 | await this.postRepository.update(id, data); 182 | return {}; 183 | } catch (error: any) { 184 | this.logger.error(error.message, error.stack); 185 | throw error; 186 | } 187 | } 188 | 189 | async delete(userId: number, id: number) { 190 | try { 191 | const post: Post | null = await this.postRepository.findById(id); 192 | if (!post) 193 | throw new BadRequestException(getResponseMessage("POST_NOT_EXIST")); 194 | 195 | const deletedPost: Post = await this.postRepository.delete(id); 196 | 197 | await this.deleteFileQueue.add({ 198 | filename: deletedPost.cover, 199 | filePath: "./uploads/posts/", 200 | isFolder: false, 201 | }); 202 | return post.id; 203 | } catch (error: any) { 204 | this.logger.error(error.message, error.stack); 205 | throw error; 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/modules/post/post.utility.ts: -------------------------------------------------------------------------------- 1 | export function getCategoriesData(ids: string[] | number[]): any[] { 2 | return ids.map((a) => { 3 | return { 4 | assignedAt: new Date(), 5 | category: { 6 | connect: { 7 | id: Number(a), 8 | }, 9 | }, 10 | }; 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from "@nestjs/common"; 2 | import { PrismaService } from "./prisma.service"; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService], 8 | }) 9 | export class PrismaModule { } -------------------------------------------------------------------------------- /src/modules/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common' 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect() 8 | } 9 | 10 | 11 | async enableShutdownHooks(app: INestApplication) { 12 | this.$on('beforeExit', async () => { 13 | await app.close(); 14 | }) 15 | } 16 | } -------------------------------------------------------------------------------- /src/modules/queues/consumers/delete-file.consumer.ts: -------------------------------------------------------------------------------- 1 | import { OnQueueError, Process, Processor } from "@nestjs/bull"; 2 | import { QueuesConstant } from "../../../shared/constants/queues.constant"; 3 | import { Job } from "bull"; 4 | import { deleteFileQueue } from "../../../shared/interfaces/queues.interface"; 5 | 6 | import { promises as fs } from "fs"; 7 | import path from "path"; 8 | 9 | @Processor(QueuesConstant.DELETE_FILE) 10 | export class DeleteFileConsumer { 11 | constructor() {} 12 | 13 | @Process() 14 | async handleDeleteFile(job: Job) { 15 | try { 16 | const isFolder: boolean = job.data.isFolder; 17 | const fileName: string = job.data.filename; 18 | const filePath: string = job.data.filePath; 19 | const fullPath: string = path.resolve( 20 | path.join(`${filePath}/${fileName}`) 21 | ); 22 | await fs.unlink(fullPath); 23 | } catch (e) { 24 | throw e; 25 | } 26 | } 27 | @OnQueueError() 28 | onError(er) { 29 | console.log(er); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/queues/consumers/reSize-file.consumer.ts: -------------------------------------------------------------------------------- 1 | import { Process, Processor } from "@nestjs/bull"; 2 | import { Job } from "bull"; 3 | import { QueuesConstant } from "../../../shared/constants/queues.constant"; 4 | import { ReSizeFileQueue } from "../../../shared/interfaces/queues.interface"; 5 | import { ResizeService } from "../../upload/resize.service"; 6 | import { writeFile } from "fs/promises"; 7 | 8 | @Processor(QueuesConstant.RESIZE_FILE) 9 | export class ReSizeFileConsumer { 10 | constructor(private resizeService: ResizeService) {} 11 | @Process() 12 | async handler(job: Job) { 13 | try { 14 | const data: ReSizeFileQueue = job.data; 15 | const filePath: string = data.filePath; 16 | const buffer = await this.resizeService.withPath( 17 | filePath, 18 | data.width, 19 | data.height 20 | ); 21 | await writeFile(filePath, buffer); 22 | } catch (e) { 23 | throw e; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/queues/consumers/send-welcome.consumer.ts: -------------------------------------------------------------------------------- 1 | import { OnQueueCompleted, Process, Processor } from "@nestjs/bull"; 2 | import { QueuesConstant } from "../../../shared/constants/queues.constant"; 3 | import { Job } from "bull"; 4 | import { welcomeEmailQueue } from "../../../shared/interfaces/queues.interface"; 5 | import { MailService } from "../../mail/mail.service"; 6 | import { User } from "../../../shared/interfaces/user.interface"; 7 | 8 | @Processor(QueuesConstant.SEND_WELCOME_EMAIL) 9 | export class SendWelcomeEmailConsumer { 10 | constructor(private mailService: MailService) {} 11 | @Process() 12 | async handleSender(job: Job): Promise { 13 | try { 14 | const user: User = job.data.user; 15 | await this.mailService.sendWelcome(user); 16 | } catch (e) { 17 | throw e; 18 | } 19 | } 20 | 21 | @OnQueueCompleted() 22 | logCompleted(job: Job) { 23 | // console.log(`Completed: ${job.id}`); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/queues/queues.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from "@nestjs/common"; 2 | import { BullModule } from "@nestjs/bull"; 3 | import { QueuesConstant } from "../../shared/constants/queues.constant"; 4 | import { SendWelcomeEmailConsumer } from "./consumers/send-welcome.consumer"; 5 | import { ConfigService } from "@nestjs/config"; 6 | import { DeleteFileConsumer } from "./consumers/delete-file.consumer"; 7 | import { ReSizeFileConsumer } from "./consumers/reSize-file.consumer"; 8 | import { UploadModule } from "../upload/upload.module"; 9 | 10 | const importsAndExports = [ 11 | BullModule.registerQueue( 12 | { 13 | name: QueuesConstant.SEND_WELCOME_EMAIL, 14 | defaultJobOptions: { priority: 1 }, 15 | }, 16 | { name: QueuesConstant.DELETE_FILE }, 17 | { 18 | name: QueuesConstant.RESIZE_FILE, 19 | defaultJobOptions: { 20 | priority: 2, 21 | attempts: 3, 22 | removeOnComplete: true, 23 | removeOnFail: true, 24 | }, 25 | } 26 | ), 27 | ]; 28 | 29 | const providerAndExports = [ 30 | SendWelcomeEmailConsumer, 31 | DeleteFileConsumer, 32 | ReSizeFileConsumer, 33 | ]; 34 | @Global() 35 | @Module({ 36 | imports: [ 37 | BullModule.forRootAsync({ 38 | useFactory: (config: ConfigService) => ({ 39 | redis: { 40 | host: config.get("REDIS_URL"), 41 | }, 42 | }), 43 | inject: [ConfigService], 44 | }), 45 | ...importsAndExports, 46 | UploadModule, 47 | ], 48 | providers: [...providerAndExports], 49 | exports: [...importsAndExports, ...providerAndExports], 50 | }) 51 | export class QueuesModule {} 52 | -------------------------------------------------------------------------------- /src/modules/upload/filters/post.filter.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export function postFilter(req, file, cb) { 4 | const maxSize = 1024 * 1024 * 5 // 5MB 5 | if (file.size > maxSize) { 6 | cb(new BadRequestException('FILE_SIZE_TOO_LARGE'), false) 7 | } 8 | if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { 9 | return cb(new BadRequestException("INVALID_FILE_FORMAT"), false) 10 | } 11 | cb(null, true); 12 | } -------------------------------------------------------------------------------- /src/modules/upload/resize.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import sharp from "sharp"; 3 | 4 | @Injectable() 5 | export class ResizeService { 6 | constructor() {} 7 | 8 | public async withBuffer( 9 | buffer: Buffer, 10 | width: number, 11 | height: number 12 | ): Promise { 13 | return sharp(buffer).resize(width, height).toBuffer(); 14 | } 15 | 16 | public async withPath( 17 | path: string, 18 | width: number, 19 | height: number 20 | ): Promise { 21 | return sharp(path).resize(width, height).toBuffer(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/upload/upload.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | UploadedFile, 5 | UseGuards, 6 | UseInterceptors, 7 | } from "@nestjs/common"; 8 | import { FileInterceptor } from "@nestjs/platform-express"; 9 | import { 10 | ApiBearerAuth, 11 | ApiConsumes, 12 | ApiOperation, 13 | ApiTags, 14 | } from "@nestjs/swagger"; 15 | import { ApiFile } from "src/shared/decorators/api-File.decorator"; 16 | import CheckRoleGuard from "src/shared/guards/check-roles.guard"; 17 | import { postFilter } from "./filters/post.filter"; 18 | import { uploadService } from "./upload.service"; 19 | import { postStorage } from "./upload.storages"; 20 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 21 | import { authGuard } from "../../shared/guards/auth.guard"; 22 | 23 | @ApiTags("Upload File") 24 | @ApiBearerAuth() 25 | @UseInterceptors(ResponseInterceptor) 26 | @UseGuards(CheckRoleGuard(["ADMIN"])) 27 | @UseGuards(authGuard(false)) 28 | @Controller("uploads") 29 | export class UploadController { 30 | constructor(private uploadService: uploadService) {} 31 | 32 | @ApiOperation({ 33 | summary: "upload a photo for post", 34 | description: `Required Permission: 'ADMIN'`, 35 | }) 36 | @ApiConsumes("multipart/form-data") 37 | @ApiFile("cover") 38 | @Post("posts") 39 | @UseInterceptors( 40 | FileInterceptor("cover", { 41 | storage: postStorage(), 42 | fileFilter: postFilter, 43 | }) 44 | ) 45 | async uploadFile(@UploadedFile() file: Express.Multer.File) { 46 | return this.uploadService.upload(file); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/upload/upload.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { MulterModule } from "@nestjs/platform-express"; 3 | import { ResizeService } from "./resize.service"; 4 | import { UploadController } from "./upload.controller"; 5 | import { uploadService } from "./upload.service"; 6 | 7 | const providersAndExports = [ResizeService]; 8 | 9 | @Module({ 10 | imports: [ 11 | MulterModule.registerAsync({ 12 | useFactory: () => ({ 13 | dest: "./uploads", 14 | }), 15 | }), 16 | ], 17 | controllers: [UploadController], 18 | providers: [uploadService, ...providersAndExports], 19 | exports: [...providersAndExports], 20 | }) 21 | export class UploadModule {} 22 | -------------------------------------------------------------------------------- /src/modules/upload/upload.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from "@nestjs/common"; 2 | import { getResponseMessage } from "src/shared/constants/messages.constant"; 3 | import { ResizeService } from "./resize.service"; 4 | import { stat, mkdir } from "fs/promises"; 5 | import { InjectQueue } from "@nestjs/bull"; 6 | import { QueuesConstant } from "../../shared/constants/queues.constant"; 7 | import { Queue } from "bull"; 8 | import { ReSizeFileQueue } from "../../shared/interfaces/queues.interface"; 9 | @Injectable() 10 | export class uploadService { 11 | constructor( 12 | private readonly resizeService: ResizeService, 13 | @InjectQueue(QueuesConstant.RESIZE_FILE) 14 | private resizeFileQueue: Queue 15 | ) {} 16 | 17 | async upload(file: Express.Multer.File) { 18 | try { 19 | if (!file) 20 | throw new BadRequestException(getResponseMessage("FILE_IS_REQUIRED")); 21 | 22 | const path_ = `./uploads/posts`; 23 | 24 | const state = await stat(path_); 25 | if (!state.isDirectory()) { 26 | await mkdir(path_); 27 | } 28 | 29 | await this.resizeFileQueue.add({ 30 | filePath: file.path, 31 | width: 500, 32 | height: 500, 33 | }); 34 | // await writeFile(file.path, file); 35 | 36 | return file.filename; 37 | } catch (error) { 38 | throw error; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/upload/upload.storages.ts: -------------------------------------------------------------------------------- 1 | import { diskStorage } from "multer"; 2 | import { promises } from "fs"; 3 | import BestString from "best-string"; 4 | export function postStorage() { 5 | return diskStorage({ 6 | destination: async function (req, file, cb) { 7 | const path_ = `./uploads/posts`; 8 | 9 | try { 10 | const state = await promises.stat(path_); 11 | if (!state.isDirectory()) { 12 | await promises.mkdir(path_); 13 | } 14 | } catch (error) { 15 | await promises.mkdir(path_); 16 | } finally { 17 | cb(null, path_); 18 | } 19 | }, 20 | filename: function (req, file, cb) { 21 | const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); 22 | const filename: string = new BestString(file.originalname) 23 | .noRtlCharacters() 24 | .replaceGlobal(" ", "-") 25 | .build(); 26 | cb(null, uniqueSuffix + "-" + filename); 27 | }, 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/users/__test__/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { UsersService } from "../users.service"; 2 | import { UsersRepository } from "../users.repository"; 3 | import { User } from "../../../shared/interfaces/user.interface"; 4 | import { BadRequestException } from "@nestjs/common"; 5 | import { RoleType } from "../../../shared/interfaces/role.interface"; 6 | const getFakeUser = (): User => { 7 | return { 8 | username: "mrx", 9 | email: "mrx@gmail.com", 10 | role: "ADMIN", 11 | id: 1, 12 | password: "hash", 13 | createdAt: new Date(), 14 | updatedAt: new Date(), 15 | }; 16 | }; 17 | describe("UserService", function () { 18 | let usersService: UsersService; 19 | let usersRepository: UsersRepository; 20 | beforeEach(() => { 21 | usersRepository = new UsersRepository(jest.fn() as unknown as any); 22 | usersService = new UsersService(usersRepository); 23 | }); 24 | 25 | describe("getProfile()", function () { 26 | it("should return user without password field", () => { 27 | const user: User = getFakeUser(); 28 | const result = usersService.getProfile(user); 29 | 30 | expect(result).toEqual(user); 31 | }); 32 | it("should password is undefined", () => { 33 | const user: User = getFakeUser(); 34 | const result = usersService.getProfile(user); 35 | 36 | expect(result.password).toBeUndefined(); 37 | }); 38 | }); 39 | describe("updateRole()", function () { 40 | it("should reject 'INVALID_ROLE' when set unknown role", () => { 41 | const user: User = getFakeUser(); 42 | const role = "MANAGER"; 43 | expect(usersService.updateRole(user.id, role)).rejects.toEqual( 44 | new BadRequestException("INVALID_ROLE") 45 | ); 46 | }); 47 | it("should reject 'INVALID_USER_ID' when set unknown userId", async () => { 48 | const user: User = getFakeUser(); 49 | jest.spyOn(usersRepository, "update").mockImplementation(() => { 50 | throw new Error("invalid"); //database error 51 | }); 52 | const role: RoleType = "ADMIN"; 53 | await expect(usersService.updateRole(user.id, role)).rejects.toEqual( 54 | new BadRequestException("INVALID_USER_ID") 55 | ); 56 | }); 57 | it("should update user role and return role", async () => { 58 | const user: User = getFakeUser(); 59 | const role: RoleType = "ADMIN"; 60 | jest 61 | .spyOn(usersRepository, "update") 62 | .mockImplementation(() => Promise.resolve(user)); 63 | await expect(usersService.updateRole(user.id, role)).resolves.toBe(role); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/modules/users/dtos/role.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | import { Role } from "../../../shared/interfaces/role.interface"; 4 | 5 | export class RoleDto { 6 | @ApiProperty({ 7 | enum: Role, 8 | description: "The role of the user", 9 | required: true, 10 | example: "ADMIN", 11 | }) 12 | @IsString() 13 | @IsNotEmpty() 14 | name: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Param, 6 | Put, 7 | UseGuards, 8 | UseInterceptors, 9 | } from "@nestjs/common"; 10 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 11 | import { getUser } from "src/shared/decorators/req-user.decorator"; 12 | import CheckRoleGuard from "src/shared/guards/check-roles.guard"; 13 | import { User } from "src/shared/interfaces/user.interface"; 14 | import { RoleDto } from "./dtos/role.dto"; 15 | import { UsersService } from "./users.service"; 16 | import { ResponseInterceptor } from "../../shared/interceptors/response.interceptor"; 17 | import { authGuard } from "../../shared/guards/auth.guard"; 18 | 19 | @ApiBearerAuth() 20 | @UseInterceptors(ResponseInterceptor) 21 | @UseGuards(authGuard(false)) 22 | @Controller("users") 23 | export class UsersController { 24 | constructor(private readonly usersService: UsersService) {} 25 | 26 | @ApiOperation({ 27 | summary: "get profile", 28 | }) 29 | @ApiTags("Current User") 30 | @Get("/@me") 31 | profile(@getUser() user: User) { 32 | return this.usersService.getProfile(user); 33 | } 34 | 35 | @ApiOperation({ 36 | summary: "update user role by UserId", 37 | description: `Required Permission: 'ADMIN'`, 38 | }) 39 | @ApiTags("Manage User") 40 | @Put("/role/:userId") 41 | @UseGuards(CheckRoleGuard(["ADMIN"])) 42 | updateRole( 43 | @Param("userId") userId: string, 44 | @Body() roleDto: RoleDto 45 | ): Promise { 46 | return this.usersService.updateRole(Number(userId), roleDto.name); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { UsersService } from './users.service'; 4 | import { UsersController } from './users.controller'; 5 | import { UsersRepository } from './users.repository'; 6 | import { PrismaService } from 'src/modules/prisma/prisma.service'; 7 | 8 | 9 | @Module({ 10 | imports: [ 11 | 12 | ], 13 | controllers: [ 14 | UsersController 15 | ], 16 | providers: [ 17 | UsersService, 18 | UsersRepository, 19 | ], 20 | exports: [ 21 | UsersService, 22 | UsersRepository 23 | ], 24 | }) 25 | export class UserModule { } -------------------------------------------------------------------------------- /src/modules/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | import { 4 | User, 5 | UserCreateInput, 6 | UserUpdateInput, 7 | } from "src/shared/interfaces/user.interface"; 8 | import { Prisma } from "@prisma/client"; 9 | import { PrismaService } from "src/modules/prisma/prisma.service"; 10 | 11 | @Injectable() 12 | export class UsersRepository { 13 | constructor(private prisma: PrismaService) {} 14 | 15 | async find(params: { 16 | skip?: number; 17 | take?: number; 18 | cursor?: Prisma.UserWhereUniqueInput; 19 | where?: Prisma.UserWhereInput; 20 | orderBy?: Prisma.UserOrderByWithRelationInput; 21 | }): Promise { 22 | return this.prisma.user.findMany({ 23 | ...params, 24 | }); 25 | } 26 | 27 | async findById(id: number): Promise { 28 | return this.prisma.user.findUnique({ 29 | where: { 30 | id: id, 31 | }, 32 | }); 33 | } 34 | 35 | async findOneByEmailAndUsername( 36 | email: string, 37 | username: string 38 | ): Promise { 39 | return this.prisma.user.findUnique({ 40 | where: { 41 | email, 42 | username, 43 | }, 44 | }); 45 | } 46 | 47 | async findOneByUsername(username: string): Promise { 48 | return this.prisma.user.findUnique({ 49 | where: { 50 | username, 51 | }, 52 | }); 53 | } 54 | 55 | async findOneByEmail(email: string): Promise { 56 | return this.prisma.user.findUnique({ 57 | where: { 58 | email, 59 | }, 60 | }); 61 | } 62 | 63 | async findByEmailOrUsername( 64 | email: string, 65 | username: string 66 | ): Promise { 67 | return this.prisma.user.findMany({ 68 | where: { 69 | OR: [{ email }, { username }], 70 | }, 71 | }); 72 | } 73 | 74 | async create(user: UserCreateInput): Promise { 75 | return this.prisma.user.create({ 76 | data: { 77 | ...user, 78 | }, 79 | }); 80 | } 81 | 82 | async update(id: number, entity: UserUpdateInput): Promise { 83 | return this.prisma.user.update({ where: { id: id }, data: entity }); 84 | } 85 | async deleteOneWithId(id: number): Promise { 86 | return this.prisma.user.delete({ where: { id: id } }); 87 | } 88 | // async delete(id: number): Promise { 89 | // return this.repository.delete(id) 90 | // } 91 | } 92 | -------------------------------------------------------------------------------- /src/modules/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from "@nestjs/common"; 2 | import { User } from "src/shared/interfaces/user.interface"; 3 | import { UsersRepository } from "./users.repository"; 4 | import { getResponseMessage } from "../../shared/constants/messages.constant"; 5 | import { Role, RoleType } from "../../shared/interfaces/role.interface"; 6 | 7 | @Injectable() 8 | export class UsersService { 9 | constructor(private userRepo: UsersRepository) {} 10 | 11 | getProfile(user: User) { 12 | const myUser = user; 13 | delete myUser.password; 14 | return myUser; 15 | } 16 | 17 | async updateRole(userId: number, role: string) { 18 | try { 19 | const hasRole: RoleType | null = Role[role]; 20 | if (!hasRole) 21 | throw new BadRequestException(getResponseMessage("INVALID_ROLE")); 22 | 23 | try { 24 | await this.userRepo.update(userId, { 25 | role: hasRole, 26 | }); 27 | return hasRole; 28 | } catch (e) { 29 | throw new BadRequestException(getResponseMessage("INVALID_USER_ID")); 30 | } 31 | } catch (error) { 32 | throw error; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/config/multer.config.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { 3 | MulterModuleOptions, 4 | MulterOptionsFactory, 5 | } from '@nestjs/platform-express'; 6 | import { diskStorage } from 'multer'; 7 | 8 | @Injectable() 9 | export class MulterConfig implements MulterOptionsFactory { 10 | createMulterOptions(): MulterModuleOptions { 11 | return { 12 | dest: './uploads', 13 | fileFilter: (req, file, cb) => { 14 | // check size 15 | const maxSize = 1024 * 1024 * 2// 2MB 16 | if (file.size > maxSize) { 17 | return cb(new Error('File size is too large'), false) 18 | } 19 | if (file.mimetype.startsWith('image')) { 20 | cb(null, true); 21 | } else { 22 | cb(new Error('Invalid file type'), false); 23 | } 24 | }, 25 | storage: diskStorage({ 26 | destination: './uploads', 27 | filename: (req, file, cb) => { 28 | const randomName = Array(32).fill(null).map(() => (Math.round(Math.random() * 16)).toString(16)).join('') 29 | cb(null, `${randomName}-${file.originalname}`) 30 | } 31 | }) 32 | 33 | 34 | }; 35 | } 36 | } -------------------------------------------------------------------------------- /src/shared/constants/mail.constant.ts: -------------------------------------------------------------------------------- 1 | export enum MailConstant { 2 | FROM = "noreply@blog.com", //or process.env..... 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/constants/messages.constant.ts: -------------------------------------------------------------------------------- 1 | enum responseMessages { 2 | SUCCESS = "SUCCESS", 3 | FAILURE = "FAILURE", 4 | ERROR = "ERROR", 5 | NOT_FOUND = "NOT_FOUND", 6 | BAD_REQUEST = "BAD_REQUEST", 7 | UNAUTHORIZED = "UNAUTHORIZED", 8 | FORBIDDEN = "FORBIDDEN", 9 | CONFLICT = "CONFLICT", 10 | SERVER_ERROR = "SERVER_ERROR", 11 | NOT_IMPLEMENTED = "NOT_IMPLEMENTED", 12 | BAD_GATEWAY = "BAD_GATEWAY", 13 | SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE", 14 | GATEWAY_TIMEOUT = "GATEWAY_TIMEOUT", 15 | UNSUPPORTED_MEDIA_TYPE = "UNSUPPORTED_MEDIA_TYPE", 16 | UNPROCESSABLE_ENTITY = "UNPROCESSABLE_ENTITY", 17 | TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS", 18 | INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR", 19 | INVALID_USER_ID = "INVALID_USER_ID", 20 | INVALID_ROLE = "INVALID_ROLE", 21 | FILE_IS_REQUIRED = "FILE_IS_REQUIRED", 22 | FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE", 23 | INVALID_FILE_FORMAT = "INVALID_FILE_FORMAT", 24 | FILE_NOT_EXIST = "FILE_NOT_EXIST", 25 | POST_NOT_EXIST = "POST_NOT_EXIST", 26 | CATEGORY_EXIST = "CATEGORY_EXIST", 27 | INVALID_ID = "INVALID_ID", 28 | TAGS_INVALID = "TAGS_INVALID", 29 | CATEGORIES_NOT_EXIST = "CATEGORIES_NOT_EXIST", 30 | REPLY_COMMENT_NOT_FOUND = "REPLY_COMMENT_NOT_FOUND", 31 | } 32 | 33 | export function getResponseMessage(message: keyof typeof responseMessages) { 34 | return responseMessages[message]; 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/constants/queues.constant.ts: -------------------------------------------------------------------------------- 1 | export enum QueuesConstant { 2 | SEND_WELCOME_EMAIL = "SEND_WELCOME_EMAIL", 3 | DELETE_FILE = "DELETE_FILE", 4 | RESIZE_FILE = "RESIZE_FILE", 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/decorators/api-File.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiBody } from '@nestjs/swagger'; 2 | 3 | export const ApiFile = (fileName: string = 'file'): MethodDecorator => ( 4 | target: any, 5 | propertyKey: string, 6 | descriptor: PropertyDescriptor, 7 | ) => { 8 | ApiBody({ 9 | schema: { 10 | type: 'object', 11 | properties: { 12 | [fileName]: { 13 | type: 'string', 14 | format: 'binary', 15 | }, 16 | }, 17 | 18 | }, 19 | })(target, propertyKey, descriptor); 20 | }; 21 | 22 | // full details: https://github.com/nestjs/swagger/issues/417 -------------------------------------------------------------------------------- /src/shared/decorators/req-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from "@nestjs/common"; 2 | import { Request } from "express"; 3 | import { User } from "@prisma/client"; 4 | 5 | export const getUser = createParamDecorator( 6 | (data: keyof User, ctx: ExecutionContext): T => { 7 | const request = ctx.switchToHttp().getRequest(); 8 | const user = request["user"]; 9 | 10 | return data ? user?.[data] : (user as T); 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /src/shared/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | UnauthorizedException, 5 | } from "@nestjs/common"; 6 | import { AuthGuard as _AuthGuard } from "@nestjs/passport"; 7 | 8 | @Injectable() 9 | export class AuthGuard extends _AuthGuard("jwt") { 10 | constructor(private canNext: boolean) { 11 | super(); 12 | } 13 | 14 | canActivate(context: ExecutionContext) { 15 | return super.canActivate(context); 16 | } 17 | 18 | handleRequest(err, user, info) { 19 | if (err || (!user && !this.canNext)) { 20 | throw err || new UnauthorizedException(); 21 | } 22 | return user || null; 23 | } 24 | } 25 | 26 | export function authGuard(canNext: boolean) { 27 | return new AuthGuard(canNext); 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/guards/check-roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | } from "@nestjs/common"; 6 | import { Reflector } from "@nestjs/core"; 7 | import { Observable } from "rxjs"; 8 | import { User } from "../interfaces/user.interface"; 9 | import { RoleType } from "../interfaces/role.interface"; 10 | 11 | function CheckRoleGuard(roles: Array): any { 12 | class _checkRoleGuard implements CanActivate { 13 | constructor(private refactor: Reflector) {} 14 | 15 | canActivate( 16 | context: ExecutionContext 17 | ): boolean | Promise | Observable { 18 | const user = context.switchToHttp().getRequest().user as User; 19 | if (roles.length === 0) return true; 20 | const hasRole: Array = roles.map((role) => role == user.role); 21 | if (hasRole.includes(true)) return true; 22 | 23 | throw new ForbiddenException("PERMISSION_DENIED"); 24 | } 25 | } 26 | return _checkRoleGuard; 27 | } 28 | 29 | export default CheckRoleGuard; 30 | -------------------------------------------------------------------------------- /src/shared/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from "@nestjs/common"; 7 | 8 | import { Observable } from "rxjs"; 9 | import { map } from "rxjs/operators"; 10 | export interface Response { 11 | statusCode: number; 12 | data: T; 13 | } 14 | 15 | @Injectable() 16 | export class ResponseInterceptor implements NestInterceptor> { 17 | intercept( 18 | context: ExecutionContext, 19 | next: CallHandler 20 | ): Observable> | Promise>> { 21 | return next.handle().pipe( 22 | map((data) => ({ 23 | statusCode: context.switchToHttp().getResponse().statusCode, 24 | data, 25 | })) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/interfaces/categories.interface.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, Category as _Category } from "@prisma/client"; 2 | 3 | export interface Category extends _Category {} 4 | 5 | export interface CategoryCreateInput extends Prisma.CategoryCreateInput {} 6 | 7 | export interface CategoryUpdateInput extends Prisma.CategoryUpdateInput {} 8 | -------------------------------------------------------------------------------- /src/shared/interfaces/comment.interface.ts: -------------------------------------------------------------------------------- 1 | import { Comment as _Comment, Prisma } from "@prisma/client"; 2 | 3 | export interface Comment extends _Comment {} 4 | export interface CommentCreateInput 5 | extends Omit { 6 | replyId: number; 7 | authorId: number; 8 | postId: number; 9 | } 10 | 11 | export interface CommentWithChilds extends Comment { 12 | childs: Comment[]; 13 | } 14 | 15 | export interface CommentWithRelation extends CommentWithChilds { 16 | author: { 17 | username: string; 18 | id: number; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/interfaces/mail.interface.ts: -------------------------------------------------------------------------------- 1 | import { ISendMailOptions } from "@nestjs-modules/mailer"; 2 | 3 | export interface MailSendInput extends ISendMailOptions {} 4 | -------------------------------------------------------------------------------- /src/shared/interfaces/messageLogger.interface.ts: -------------------------------------------------------------------------------- 1 | export interface MessageLogger { 2 | log(message: string): Promise | void; 3 | error(message: string, stack?: string): Promise | void; 4 | warn(message: string, stack?: string): Promise | void; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/interfaces/post.interface.ts: -------------------------------------------------------------------------------- 1 | import { Post as _Post, Prisma } from "@prisma/client"; 2 | 3 | export interface Post extends _Post {} 4 | 5 | export interface PostCreateInput 6 | extends Omit { 7 | authorId: number; 8 | } 9 | 10 | export interface PostUpdateInput extends Partial {} 11 | -------------------------------------------------------------------------------- /src/shared/interfaces/queues.interface.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user.interface"; 2 | 3 | export interface welcomeEmailQueue { 4 | user: User; 5 | } 6 | 7 | export interface deleteFileQueue { 8 | filename: string; 9 | filePath: string; 10 | isFolder: boolean; 11 | } 12 | 13 | export interface ReSizeFileQueue { 14 | filePath: string; 15 | width: number; 16 | height: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/interfaces/repository.interface.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | export interface BatchPayload extends Prisma.BatchPayload {} 4 | -------------------------------------------------------------------------------- /src/shared/interfaces/role.interface.ts: -------------------------------------------------------------------------------- 1 | import { Role as _Role } from "@prisma/client"; 2 | 3 | export type RoleType = _Role; 4 | export const Role = _Role; 5 | -------------------------------------------------------------------------------- /src/shared/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, User as _User } from "@prisma/client"; 2 | 3 | export interface User extends _User {} 4 | 5 | export type UserCreateInput = Prisma.UserCreateInput; // Omit; 6 | 7 | export type UserUpdateInput = Prisma.UserUpdateInput; 8 | -------------------------------------------------------------------------------- /src/shared/utils/fileValidator.util.ts: -------------------------------------------------------------------------------- 1 | import { promises } from 'fs' 2 | 3 | export async function fileHasExist(name: string, path: string) { 4 | 5 | try { 6 | path = path.replace(/\\/g, '/'); 7 | let fullPath = path + '/' + name; 8 | let extension = await promises.stat(fullPath) 9 | 10 | return extension.isFile() 11 | 12 | } catch (error) { 13 | return false 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /templates/welcome.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | title 5 | 6 | welcome <%=username%> ! 🤞 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import request from "supertest"; 3 | import { runAndGetAppFixture } from "./fixtures/startapp.fixture"; 4 | import { PrismaService } from "../src/modules/prisma/prisma.service"; 5 | import { JwtService } from "@nestjs/jwt"; 6 | import { User } from "@prisma/client"; 7 | import { createUserFixture } from "./fixtures/createUser.fixture"; 8 | import { createJwtFixture } from "./fixtures/createJwt.fixture"; 9 | 10 | describe("AuthController (e2e)", () => { 11 | let app: INestApplication; 12 | let prismaService: PrismaService; 13 | let jwtService: JwtService; 14 | let testUser: User; 15 | let jwt: string; 16 | 17 | beforeAll(async () => { 18 | app = await runAndGetAppFixture(); 19 | prismaService = app.get(PrismaService); 20 | jwtService = app.get(JwtService); 21 | }); 22 | 23 | beforeEach(async () => { 24 | testUser = await createUserFixture(prismaService); 25 | jwt = await createJwtFixture(app, testUser.id); 26 | }); 27 | 28 | afterEach(async () => { 29 | await prismaService.user.deleteMany({ where: { id: testUser.id } }); 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | afterAll(async () => { 34 | await app.close(); 35 | }, 100000); 36 | 37 | describe("signup", function () { 38 | it("should response 400 when send invalid data", async function () { 39 | const response = await request(app.getHttpServer()) 40 | .post("/auth/signup") 41 | .send({ username: "badValue", email: 1, password: "" }); 42 | 43 | expect(response.statusCode).toBe(400); 44 | }); 45 | 46 | it("should response 'Email must be a gmail account'", async function () { 47 | const response = await request(app.getHttpServer()) 48 | .post("/auth/signup") 49 | .send({ 50 | username: "badValue", 51 | email: "fake@hotmail.com", 52 | password: "aw151510125", 53 | }); 54 | 55 | expect(response.body.message).toContain("Email must be a gmail account"); 56 | }); 57 | 58 | it("should response jwt", async function () { 59 | let fakeJwt: string = "test.test.test"; 60 | jest.spyOn(jwtService, "sign").mockReturnValue(fakeJwt); 61 | const response = await request(app.getHttpServer()) 62 | .post("/auth/signup") 63 | .send({ 64 | username: Date.now().toString(), 65 | email: "fake@gmail.com", 66 | password: "1234567890#$%", 67 | }); 68 | expect(response.body.data).toBe(fakeJwt); 69 | expect(response.statusCode).toBe(201); 70 | }); 71 | }); 72 | 73 | describe("signing", function () { 74 | it("should response 401", async function () { 75 | const response = await request(app.getHttpServer()) 76 | .post("/auth/signing") 77 | .send({ username: testUser.username, password: "badPassword" }); 78 | 79 | expect(response.statusCode).toBe(401); 80 | }); 81 | it("should response jwt token", async function () { 82 | const fakeJwt: string = "test.test.test"; 83 | jest.spyOn(jwtService, "sign").mockReturnValue(fakeJwt); 84 | 85 | const response = await request(app.getHttpServer()) 86 | .post("/auth/signing") 87 | .send({ username: testUser.username, password: "hashedPassword" }); 88 | expect(response.body.data).toBe(fakeJwt); 89 | expect(response.statusCode).toBe(200); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/config.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sajjadmrx/Blog-nestJs/3786691be8a9aea5419fc30dd50ce2ab7f0e18ef/test/config.ts -------------------------------------------------------------------------------- /test/data/user.ts: -------------------------------------------------------------------------------- 1 | export const user = { 2 | id: 1, 3 | username: "sajjad", 4 | email: "fake@gmail.com", 5 | password: "test", 6 | }; 7 | -------------------------------------------------------------------------------- /test/fixtures/createJwt.fixture.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from "@nestjs/jwt"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import Configs from "../../src/configuration"; 5 | 6 | export async function createJwtFixture( 7 | app: INestApplication, 8 | userId: number 9 | ): Promise { 10 | const jwtService = app.get(JwtService); 11 | const config = app.get(ConfigService); 12 | return jwtService.signAsync( 13 | { userId: userId }, 14 | { 15 | expiresIn: "20d", 16 | secret: config.get("JWT_SECRET"), 17 | } 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/createUser.fixture.ts: -------------------------------------------------------------------------------- 1 | import { PrismaService } from "../../src/modules/prisma/prisma.service"; 2 | import { User } from "@prisma/client"; 3 | import bcrypt from "bcrypt"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | 6 | export async function createUserFixture( 7 | prismaService: PrismaService 8 | ): Promise { 9 | const pass = await bcrypt.hash("hashedPassword", 10); 10 | let username = uuidv4(); 11 | return prismaService.user.create({ 12 | data: { 13 | username: username, 14 | email: `${username}@gmail.com`, 15 | password: pass, 16 | }, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/startapp.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { AppModule } from "../../src/app.module"; 3 | import { INestApplication, ValidationPipe } from "@nestjs/common"; 4 | 5 | export async function runAndGetAppFixture(): Promise { 6 | const moduleFixture: TestingModule = await Test.createTestingModule({ 7 | imports: [AppModule], 8 | }).compile(); 9 | 10 | const app = moduleFixture.createNestApplication(); 11 | app.useGlobalPipes(new ValidationPipe()); 12 | await app.init(); 13 | return app; 14 | } 15 | -------------------------------------------------------------------------------- /test/jest-e2e.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | 3 | const config: Config.InitialOptions = { 4 | moduleFileExtensions: ["js", "json", "ts"], 5 | rootDir: ".", 6 | testEnvironment: "node", 7 | testRegex: ".e2e-spec.ts$", 8 | transform: { 9 | "^.+\\.(t|j)s$": "ts-jest", 10 | }, 11 | moduleDirectories: ["/../", "node_modules"], 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "testEnvironment": "node", 9 | "testRegex": ".e2e-spec.ts$", 10 | "transform": { 11 | "^.+\\.(t|j)s$": "ts-jest" 12 | }, 13 | "moduleDirectories": [ 14 | "/../", 15 | "node_modules" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/users.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { PrismaService } from "../src/modules/prisma/prisma.service"; 3 | import { runAndGetAppFixture } from "./fixtures/startapp.fixture"; 4 | import { User } from "../src/shared/interfaces/user.interface"; 5 | import { createUserFixture } from "./fixtures/createUser.fixture"; 6 | import { createJwtFixture } from "./fixtures/createJwt.fixture"; 7 | import request from "supertest"; 8 | 9 | describe("UsersController E2E", function () { 10 | let app: INestApplication; 11 | let prismaService: PrismaService; 12 | let testUser: User; 13 | let fakeJwt: string; 14 | 15 | beforeAll(async () => { 16 | app = await runAndGetAppFixture(); 17 | prismaService = app.get(PrismaService); 18 | }); 19 | 20 | beforeEach(async () => { 21 | testUser = await createUserFixture(prismaService); 22 | fakeJwt = await createJwtFixture(app, testUser.id); 23 | }); 24 | 25 | afterEach(async () => { 26 | await prismaService.user.deleteMany(); 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | afterAll(async () => { 31 | await app.close(); 32 | }, 100000); 33 | 34 | describe("Current User", function () { 35 | it("should response 401", function (done) { 36 | request(app.getHttpServer()).get("/users/@me").expect(401, done); 37 | }); 38 | it("should response user profile", async function () { 39 | const response = await request(app.getHttpServer()) 40 | .get("/users/@me") 41 | .set("Authorization", `Bearer ${fakeJwt}`); 42 | const data = response.body.data; 43 | delete testUser.password; 44 | // @ts-ignore 45 | testUser.updatedAt = testUser.updatedAt.toISOString(); 46 | // @ts-ignore 47 | testUser.createdAt = testUser.createdAt.toISOString(); 48 | expect(data).toEqual(testUser); 49 | }); 50 | }); 51 | describe("update user role by UserId", function () { 52 | it("should response 403 for role permissions", async function () { 53 | const response = await request(app.getHttpServer()) 54 | .put("/users/role/9999") 55 | .set("Authorization", `Bearer ${fakeJwt}`); 56 | expect(response.statusCode).toBe(403); 57 | }); 58 | it("should update user role", async function () { 59 | const targetUser: User = await createUserFixture(prismaService); 60 | await prismaService.user.updateMany({ 61 | where: { id: testUser.id }, 62 | data: { role: "ADMIN" }, 63 | }); 64 | 65 | const response = await request(app.getHttpServer()) 66 | .put(`/users/role/${targetUser.id}`) 67 | .set("Authorization", `Bearer ${fakeJwt}`) 68 | .send({ name: "ADMIN" }); 69 | expect(response.statusCode).toBe(200); 70 | }); 71 | it("should response INVALID_USER_ID when set invalid userId", async function () { 72 | await prismaService.user.updateMany({ 73 | where: { id: testUser.id }, 74 | data: { role: "ADMIN" }, 75 | }); 76 | 77 | const response = await request(app.getHttpServer()) 78 | .put(`/users/role/${0}`) 79 | .set("Authorization", `Bearer ${fakeJwt}`) 80 | .send({ name: "ADMIN" }); 81 | expect(response.body.message).toBe("INVALID_USER_ID"); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /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": "es2017", 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 | "esModuleInterop": true 21 | } 22 | } --------------------------------------------------------------------------------