├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── images ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── 7.png └── 8.png ├── nest-cli.json ├── package.json ├── patches └── @types+mongoose-paginate-v2+1.3.9.patch ├── src ├── app.module.ts ├── articles │ ├── articles.module.ts │ ├── dto │ │ ├── article │ │ │ ├── article-filter.input.ts │ │ │ ├── article-id.input.ts │ │ │ ├── article.type.ts │ │ │ ├── create-article.input.ts │ │ │ └── update-article.input.ts │ │ ├── comment │ │ │ ├── create-comment.input.ts │ │ │ └── delete-comment.input.ts │ │ └── success.type.ts │ ├── loaders │ │ └── article.loader.ts │ ├── resolvers │ │ ├── articles.resolver.ts │ │ ├── comment.resolver.ts │ │ └── favorite.resolver.ts │ ├── schemas │ │ ├── article.schema.ts │ │ └── comment.schema.ts │ ├── services │ │ ├── articles.service.ts │ │ ├── comment.service.ts │ │ ├── favorite.service.spec.ts │ │ └── favorite.service.ts │ └── subscription-constants.ts ├── auth │ ├── auth.guard.ts │ ├── auth.module.ts │ ├── auth.resolver.spec.ts │ ├── auth.resolver.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── decorators.ts │ ├── dto │ │ └── login.dto.ts │ ├── jwt-payload.interface.ts │ └── strategies │ │ └── jwt.strategy.ts ├── common │ ├── base.schema.ts │ ├── common.module.ts │ ├── constants.ts │ ├── images.controller.ts │ ├── mapper.ts │ ├── mongo-error.filter.ts │ ├── mongoose-testing.module.ts │ ├── pagination │ │ ├── pagination.args.ts │ │ └── pagination.type.ts │ └── services │ │ ├── crud.service.ts │ │ └── file-upload.service.ts ├── main.ts ├── schema.gql └── users │ ├── dto │ ├── create-user.input.ts │ └── update-user.input.ts │ ├── pipes │ ├── validate-password.pipe.spec.ts │ └── validate-password.pipe.ts │ ├── users.module.ts │ ├── users.resolver.spec.ts │ ├── users.resolver.ts │ ├── users.schema.ts │ └── users.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.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 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | .graphqlconfig 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # [Nestjs + Mongodb + Graphql] 3 | ![enter image description here](https://res.cloudinary.com/practicaldev/image/fetch/s--AEXRsCQG--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/i/5ysrutus0pu7ux580d5s.png) 4 | 5 | > ### [Nestjs + Graphql] codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 6 | 7 | This codebase was created to demonstrate a fully fledged fullstack application built with **Nestjs + Graphql** including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | 10 | # Screenshots 11 | 12 | **create user** 13 | 14 | 15 | 16 | **JWT login** 17 | 18 | 19 | 20 | **add article to favorite** 21 | 22 | 23 | 24 | **create article** 25 | 26 | 27 | 28 | **get all articles and comments with pagination** 29 | 30 | 31 | 32 | **add comment to article** 33 | 34 | 35 | 36 | 37 | # How it works 38 | 39 | A medium like backend server using nestjs with Graphql and mongodb as presitance layer. 40 | 41 | **Packages** 42 | 43 | 1. `@nestjs/config`: Configuration module with .env support for nestjs 44 | 2. `@nestjs/jwt`: Support JWT authentication for nestjs 45 | 3. `@nestjs/mongoose`: support [mongoose](https://mongoosejs.com/) (Mongodb ORM) for nestjs 46 | 4. `@nestjs/passport`: Nodejs authentication module that supports multiple strategies 47 | 5. `@nestjs/graphql`: Add Graphql support for nestjs 48 | 6. `graphql-subscriptions`; Add subscription with websockets for graphql 49 | 7. `dataloader`: support graphql batch loading 50 | 8. `graphql-upload`: add file upload to graphql 51 | 52 | **Why Dataloader ?** 53 | 54 | [Dataloader](https://github.com/graphql/dataloader) is used to solve the popular **N+1** problem, by batching requests and making one rquest to the database to fetch multiple objects, instead of **N** queries, this will optimize the graphql queries significantly, more about the problem can be found [here](https://medium.com/the-marcy-lab-school/what-is-the-n-1-problem-in-graphql-dd4921cb3c1a) 55 | 56 | **Graphql Upload** 57 | 58 | graphql file upload was done by `graphql-upload` node package 59 | 60 | **Graphql Schema** 61 | 62 | full graphql schema can be found at [schema.gql](https://github.com/ramzitannous/medium-graphql-nestjs/blob/master/src/schema.gql) 63 | 64 | **Graphql Subscription** 65 | 66 | subscription is done using `graphql-subscriptions`, 2 events can be subscribed: 67 | 68 | **1. when article created** 69 | 70 | 71 | **2. when a new comment added** 72 | 73 | 74 | # Getting started 75 | 76 | 1. add `.env` file with fallowing values: 77 | `SERVER_PORT=3000` 78 | `MONGODB_URI=mongodb://localhost:27017/medium` 79 | `DEBUG=true` 80 | `SECRET_KEY=secret-key` 81 | `UPLOAD_PATH=./static` 82 | 83 | 2. `yarn install` 84 | 3. `yarn start` 85 | 4. Head to `http://localhost:3000/graphql` to check graphql playground. 86 | -------------------------------------------------------------------------------- /images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/1.png -------------------------------------------------------------------------------- /images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/2.png -------------------------------------------------------------------------------- /images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/3.png -------------------------------------------------------------------------------- /images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/4.png -------------------------------------------------------------------------------- /images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/5.png -------------------------------------------------------------------------------- /images/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/6.png -------------------------------------------------------------------------------- /images/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/7.png -------------------------------------------------------------------------------- /images/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramzitannous/medium-graphql-nestjs/a6389a9d6fc36e1a37c324eb4bdb55d0cff8accf/images/8.png -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "tsConfigPath": "./tsconfig.json", 6 | "plugins": [ 7 | { 8 | "name": "@nestjs/graphql", 9 | "options": { 10 | "typeFileNameSuffix": [ 11 | ".input.ts", 12 | ".args.ts", 13 | ".type.ts", 14 | ".types.ts", 15 | ".dto.ts", 16 | ".schema.ts" 17 | ], 18 | "introspectComments": true 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medium-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": "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 | "test:e2e": "jest --config test/jest-e2e.json", 22 | "postinstall": "patch-package && rimraf node_modules/@types/mongoose" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^7.6.15", 26 | "@nestjs/config": "^0.6.3", 27 | "@nestjs/core": "^7.6.15", 28 | "@nestjs/graphql": "^7.10.6", 29 | "@nestjs/jwt": "^7.2.0", 30 | "@nestjs/mongoose": "^7.2.4", 31 | "@nestjs/passport": "^7.1.5", 32 | "@nestjs/platform-express": "^7.6.15", 33 | "@types/passport-jwt": "^3.0.5", 34 | "apollo-server-express": "^2.24.0", 35 | "bcrypt": "^5.0.1", 36 | "class-transformer": "^0.4.0", 37 | "class-validator": "^0.13.1", 38 | "dataloader": "^2.0.0", 39 | "graphql": "^15.5.0", 40 | "graphql-subscriptions": "^1.2.1", 41 | "graphql-tools": "^7.0.5", 42 | "graphql-upload": "^12.0.0", 43 | "mongoose": "^5.12.10", 44 | "mongoose-paginate-v2": "^1.3.18", 45 | "passport": "^0.4.1", 46 | "passport-jwt": "^4.0.0", 47 | "reflect-metadata": "^0.1.13", 48 | "rimraf": "^3.0.2", 49 | "rxjs": "^6.6.6" 50 | }, 51 | "devDependencies": { 52 | "@nestjs/cli": "^7.6.0", 53 | "@nestjs/schematics": "^7.3.0", 54 | "@nestjs/testing": "^7.6.15", 55 | "@types/bcrypt": "^5.0.0", 56 | "@types/express": "^4.17.12", 57 | "@types/graphql-upload": "^8.0.4", 58 | "@types/jest": "^26.0.22", 59 | "@types/mongoose-paginate-v2": "^1.3.9", 60 | "@types/node": "^14.14.36", 61 | "@types/passport-local": "^1.0.33", 62 | "@types/supertest": "^2.0.10", 63 | "@typescript-eslint/eslint-plugin": "^4.19.0", 64 | "@typescript-eslint/parser": "^4.19.0", 65 | "eslint": "^7.22.0", 66 | "eslint-config-prettier": "^8.1.0", 67 | "eslint-plugin-prettier": "^3.3.1", 68 | "fake-tag": "^2.0.0", 69 | "jest": "^26.6.3", 70 | "mongodb-memory-server": "^6.9.6", 71 | "patch-package": "^6.4.7", 72 | "prettier": "^2.2.1", 73 | "source-map-support": "^0.5.19", 74 | "supertest": "^6.1.3", 75 | "ts-jest": "^26.5.4", 76 | "ts-loader": "^8.0.18", 77 | "ts-node": "^9.1.1", 78 | "tsconfig-paths": "^3.9.0", 79 | "typescript": "^4.2.3" 80 | }, 81 | "jest": { 82 | "moduleFileExtensions": [ 83 | "js", 84 | "json", 85 | "ts" 86 | ], 87 | "rootDir": "src", 88 | "testRegex": ".*\\.spec\\.ts$", 89 | "transform": { 90 | "^.+\\.(t|j)s$": "ts-jest" 91 | }, 92 | "collectCoverageFrom": [ 93 | "**/*.(t|j)s" 94 | ], 95 | "coverageDirectory": "../coverage", 96 | "testEnvironment": "node" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /patches/@types+mongoose-paginate-v2+1.3.9.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@types/mongoose-paginate-v2/index.d.ts b/node_modules/@types/mongoose-paginate-v2/index.d.ts 2 | index 53433d2..1f0fda9 100644 3 | --- a/node_modules/@types/mongoose-paginate-v2/index.d.ts 4 | +++ b/node_modules/@types/mongoose-paginate-v2/index.d.ts 5 | @@ -10,6 +10,7 @@ 6 | // Minimum TypeScript Version: 3.2 7 | // 8 | // Based on type declarations for mongoose-paginate 5.0.0. 9 | +import mongodb = require("mongodb"); 10 | 11 | declare module 'mongoose' { 12 | interface CustomLabels { 13 | @@ -31,7 +32,7 @@ declare module 'mongoose' { 14 | select?: object | string; 15 | sort?: object | string; 16 | customLabels?: CustomLabels; 17 | - collation?: CollationOptions; 18 | + collation?: mongodb.CollationDocument; 19 | populate?: object[] | string[] | object | string | QueryPopulateOptions; 20 | lean?: boolean; 21 | leanWithId?: boolean; 22 | @@ -42,7 +43,7 @@ declare module 'mongoose' { 23 | /* If pagination is set to `false`, it will return all docs without adding limit condition. (Default: `true`) */ 24 | pagination?: boolean; 25 | projection?: any; 26 | - options?: QueryFindOptions; 27 | + options?: QueryOptions; 28 | } 29 | 30 | interface QueryPopulateOptions { 31 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ConfigModule, ConfigService } from "@nestjs/config"; 3 | import { UsersModule } from "./users/users.module"; 4 | import { MongooseModule } from "@nestjs/mongoose"; 5 | import { GraphQLModule } from "@nestjs/graphql"; 6 | import { join } from "path"; 7 | import { ArticlesModule } from "./articles/articles.module"; 8 | import * as mongoosePaginate from "mongoose-paginate-v2"; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ 13 | isGlobal: true, 14 | }), 15 | UsersModule, 16 | MongooseModule.forRootAsync({ 17 | useFactory: async (configService: ConfigService) => ({ 18 | uri: configService.get("MONGODB_URI"), 19 | connectionFactory: (connection) => { 20 | connection.plugin(mongoosePaginate); 21 | return connection; 22 | }, 23 | }), 24 | inject: [ConfigService], 25 | }), 26 | GraphQLModule.forRootAsync({ 27 | inject: [ConfigService], 28 | useFactory: (config: ConfigService) => ({ 29 | playground: true, 30 | debug: config.get("DEBUG"), 31 | autoSchemaFile: join(process.cwd(), "src/schema.gql"), 32 | installSubscriptionHandlers: true, 33 | uploads: false, 34 | subscriptions: { 35 | onConnect: (connectionParams: { Authorization: string }) => { 36 | const authorization = connectionParams.Authorization; 37 | return { authorization }; 38 | }, 39 | }, 40 | context: ({ connection, req }) => 41 | req ? req : { req: { headers: connection.context } }, 42 | }), 43 | }), 44 | ArticlesModule, 45 | ], 46 | controllers: [], 47 | providers: [], 48 | }) 49 | export class AppModule {} 50 | -------------------------------------------------------------------------------- /src/articles/articles.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ArticlesService } from "./services/articles.service"; 3 | import { ArticlesResolver } from "./resolvers/articles.resolver"; 4 | import { MongooseModule } from "@nestjs/mongoose"; 5 | import { Article, ArticleSchema } from "./schemas/article.schema"; 6 | import { ArticleLoader } from "./loaders/article.loader"; 7 | import { UsersModule } from "../users/users.module"; 8 | import { FavoriteService } from "./services/favorite.service"; 9 | import { FavoriteResolver } from "./resolvers/favorite.resolver"; 10 | import { CommentService } from "./services/comment.service"; 11 | import { CommentResolver } from "./resolvers/comment.resolver"; 12 | import { CommentSchema, Comment } from "./schemas/comment.schema"; 13 | import { CommonModule } from "../common/common.module"; 14 | 15 | @Module({ 16 | providers: [ 17 | ArticlesResolver, 18 | ArticlesService, 19 | ArticleLoader, 20 | FavoriteService, 21 | FavoriteResolver, 22 | CommentService, 23 | CommentResolver, 24 | ], 25 | imports: [ 26 | CommonModule, 27 | UsersModule, 28 | MongooseModule.forFeature([ 29 | { 30 | name: Article.name, 31 | schema: ArticleSchema, 32 | }, 33 | { 34 | name: Comment.name, 35 | schema: CommentSchema, 36 | }, 37 | ]), 38 | ], 39 | }) 40 | export class ArticlesModule {} 41 | -------------------------------------------------------------------------------- /src/articles/dto/article/article-filter.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | import { ObjectId } from "mongoose"; 3 | 4 | @InputType() 5 | export class ArticleFilterInput { 6 | slug?: string; 7 | 8 | @Field(() => String) 9 | id?: ObjectId; 10 | } 11 | -------------------------------------------------------------------------------- /src/articles/dto/article/article-id.input.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongoose"; 2 | import { Field, InputType } from "@nestjs/graphql"; 3 | 4 | @InputType() 5 | export class ArticleIdInput { 6 | @Field(() => String) 7 | articleId: ObjectId; 8 | } 9 | -------------------------------------------------------------------------------- /src/articles/dto/article/article.type.ts: -------------------------------------------------------------------------------- 1 | import { Paginated } from "../../../common/pagination/pagination.type"; 2 | import { ObjectType } from "@nestjs/graphql"; 3 | import { Article } from "../../schemas/article.schema"; 4 | 5 | @ObjectType() 6 | export class PaginatedArticle extends Paginated(Article) {} 7 | -------------------------------------------------------------------------------- /src/articles/dto/article/create-article.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, OmitType } from "@nestjs/graphql"; 2 | import { Article } from "../../schemas/article.schema"; 3 | 4 | @InputType() 5 | export class CreateArticleInput extends OmitType( 6 | Article, 7 | [ 8 | "id", 9 | "author", 10 | "createdAt", 11 | "updatedAt", 12 | "favoritedUsers", 13 | "comments", 14 | "favorited", 15 | "favoritesCount", 16 | ], 17 | InputType, 18 | ) {} 19 | -------------------------------------------------------------------------------- /src/articles/dto/article/update-article.input.ts: -------------------------------------------------------------------------------- 1 | import { CreateArticleInput } from "./create-article.input"; 2 | import { InputType, Field, PartialType, ID } from "@nestjs/graphql"; 3 | import { ObjectId } from "mongoose"; 4 | 5 | @InputType() 6 | export class UpdateArticleInput extends PartialType(CreateArticleInput) { 7 | @Field(() => ID) 8 | id: ObjectId; 9 | } 10 | -------------------------------------------------------------------------------- /src/articles/dto/comment/create-comment.input.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from "class-validator"; 2 | import { Field, InputType } from "@nestjs/graphql"; 3 | import { ObjectId } from "mongoose"; 4 | 5 | @InputType() 6 | export class CreateCommentInput { 7 | @IsString() 8 | @IsNotEmpty() 9 | body: string; 10 | 11 | @Field(() => String) 12 | @IsString() 13 | @IsNotEmpty() 14 | articleId: ObjectId; 15 | } 16 | -------------------------------------------------------------------------------- /src/articles/dto/comment/delete-comment.input.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongoose"; 2 | import { Field, InputType } from "@nestjs/graphql"; 3 | 4 | @InputType() 5 | export class DeleteCommentInput { 6 | @Field(() => String) 7 | commentId: ObjectId; 8 | 9 | @Field(() => String) 10 | articleId: ObjectId; 11 | } 12 | -------------------------------------------------------------------------------- /src/articles/dto/success.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | export class Success { 5 | success: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/articles/loaders/article.loader.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from "@nestjs/common"; 2 | import { ObjectId } from "mongoose"; 3 | import { UsersService } from "../../users/users.service"; 4 | import * as DataLoader from "dataloader"; 5 | import { User } from "../../users/users.schema"; 6 | import { Mapper } from "../../common/mapper"; 7 | import { Comment } from "../schemas/comment.schema"; 8 | import { ArticlesService } from "../services/articles.service"; 9 | import { FavoriteService } from "../services/favorite.service"; 10 | 11 | type ArticleFavoritedLoader = { articleId: ObjectId; user: User }; 12 | 13 | @Injectable({ scope: Scope.REQUEST }) 14 | export class ArticleLoader { 15 | authorsMapper = new Mapper(); 16 | 17 | constructor( 18 | private readonly userService: UsersService, 19 | private readonly articleService: ArticlesService, 20 | private readonly favoriteService: FavoriteService, 21 | ) {} 22 | 23 | public readonly batchAuthors = new DataLoader( 24 | async (authorIds: ObjectId[]) => { 25 | const authors = await this.userService.findByIds(authorIds); 26 | return this.authorsMapper.mapObjectsToId(authors, authorIds); 27 | }, 28 | ); 29 | 30 | public readonly batchCommentAuthors = new DataLoader( 31 | async (commentAuthorIds: ObjectId[]) => { 32 | const authors = await this.userService.findByIds(commentAuthorIds); 33 | return this.authorsMapper.mapObjectsToId(authors, commentAuthorIds); 34 | }, 35 | ); 36 | 37 | public readonly batchComments = new DataLoader( 38 | async (articledIds: ObjectId[]) => { 39 | return this.articleService.getCommentsForArticles(articledIds); 40 | }, 41 | ); 42 | 43 | public readonly batchFavorited = new DataLoader< 44 | ArticleFavoritedLoader, 45 | boolean 46 | >(async (favoritesByUser: ArticleFavoritedLoader[]) => { 47 | const user = favoritesByUser[0].user; 48 | return this.favoriteService.isArticlesFavoriteByUser( 49 | user, 50 | favoritesByUser.map((f) => f.articleId), 51 | ); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/articles/resolvers/articles.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Mutation, 4 | Args, 5 | Query, 6 | ResolveField, 7 | Parent, 8 | Subscription, 9 | } from "@nestjs/graphql"; 10 | import { ArticlesService } from "../services/articles.service"; 11 | import { Article } from "../schemas/article.schema"; 12 | import { CreateArticleInput } from "../dto/article/create-article.input"; 13 | import { CurrentUser } from "../../auth/decorators"; 14 | import { User } from "../../users/users.schema"; 15 | import { PaginationArgs } from "../../common/pagination/pagination.args"; 16 | import { Pagination } from "../../common/pagination/pagination.type"; 17 | import { PaginatedArticle } from "../dto/article/article.type"; 18 | import { ArticleLoader } from "../loaders/article.loader"; 19 | import { UpdateArticleInput } from "../dto/article/update-article.input"; 20 | import { ArticleIdInput } from "../dto/article/article-id.input"; 21 | import { Success } from "../dto/success.type"; 22 | import { Comment } from "../schemas/comment.schema"; 23 | import { ArticleFilterInput } from "../dto/article/article-filter.input"; 24 | import { Inject } from "@nestjs/common"; 25 | import { PUB_SUB } from "../../common/constants"; 26 | import { PubSub } from "graphql-subscriptions"; 27 | import { ARTICLE_ADDED_EVENT } from "../subscription-constants"; 28 | 29 | @Resolver(() => Article) 30 | export class ArticlesResolver { 31 | constructor( 32 | private readonly articlesService: ArticlesService, 33 | private readonly articleLoader: ArticleLoader, 34 | @Inject(PUB_SUB) private readonly pubSub: PubSub, 35 | ) {} 36 | 37 | @ResolveField(() => User) 38 | async author(@Parent() article: Article): Promise { 39 | return this.articleLoader.batchAuthors.load(article.author._id); 40 | } 41 | 42 | @ResolveField(() => [Comment]) 43 | async comments(@Parent() article: Article): Promise { 44 | return this.articleLoader.batchComments.load(article._id); 45 | } 46 | 47 | @Query(() => PaginatedArticle, { description: "get all articles" }) 48 | async allArticles( 49 | @Args("pageInfo", { nullable: true }) pageInfo: PaginationArgs, 50 | ): Promise> { 51 | return this.articlesService.findAll( 52 | {}, 53 | null, 54 | {}, 55 | pageInfo.page, 56 | pageInfo.pageSize, 57 | ); 58 | } 59 | 60 | @Query(() => Article, { description: "get article by filter" }) 61 | async getArticle( 62 | @Args("filter") articleGetInput: ArticleFilterInput, 63 | ): Promise
{ 64 | return this.articlesService.findOne({ 65 | $or: [{ _id: articleGetInput.id }, { slug: articleGetInput.slug }], 66 | }); 67 | } 68 | 69 | @Mutation(() => Article, { description: "create a new article" }) 70 | async createArticle( 71 | @Args("input") createArticleInput: CreateArticleInput, 72 | @CurrentUser() currentUser: User, 73 | ): Promise
{ 74 | return await this.articlesService.createWithUser( 75 | createArticleInput, 76 | currentUser, 77 | ); 78 | } 79 | 80 | @Mutation(() => Article, { description: "update an existing article" }) 81 | async updateArticle( 82 | @Args("input") updateArticleInput: UpdateArticleInput, 83 | ): Promise
{ 84 | return await this.articlesService.update( 85 | updateArticleInput.id, 86 | updateArticleInput, 87 | ); 88 | } 89 | 90 | @Mutation(() => Success, { description: "delete an existing article" }) 91 | async deleteArticle( 92 | @Args("input") articleDeleteInput: ArticleIdInput, 93 | ): Promise { 94 | const res = await this.articlesService.delete({ 95 | _id: articleDeleteInput.articleId, 96 | }); 97 | return { success: res }; 98 | } 99 | 100 | @Subscription(() => Article) 101 | async articleAdded() { 102 | return this.pubSub.asyncIterator(ARTICLE_ADDED_EVENT); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/articles/resolvers/comment.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Args, 3 | Mutation, 4 | Parent, 5 | ResolveField, 6 | Resolver, 7 | Subscription, 8 | } from "@nestjs/graphql"; 9 | import { Comment } from "../schemas/comment.schema"; 10 | import { CreateCommentInput } from "../dto/comment/create-comment.input"; 11 | import { CommentService } from "../services/comment.service"; 12 | import { CurrentUser } from "../../auth/decorators"; 13 | import { DeleteCommentInput } from "../dto/comment/delete-comment.input"; 14 | import { User } from "../../users/users.schema"; 15 | import { ArticleLoader } from "../loaders/article.loader"; 16 | import { COMMENT_ADDED_EVENT } from "../subscription-constants"; 17 | import { Inject } from "@nestjs/common"; 18 | import { PUB_SUB } from "../../common/constants"; 19 | import { PubSub } from "graphql-subscriptions"; 20 | 21 | @Resolver(() => Comment) 22 | export class CommentResolver { 23 | constructor( 24 | private readonly commentService: CommentService, 25 | private readonly articleLoader: ArticleLoader, 26 | @Inject(PUB_SUB) private readonly pubSub: PubSub, 27 | ) {} 28 | 29 | @Mutation(() => Comment, { description: "add a comment to existing article" }) 30 | async addComment( 31 | @Args("input") createCommentInput: CreateCommentInput, 32 | @CurrentUser() user, 33 | ): Promise { 34 | return this.commentService.addComment( 35 | createCommentInput.articleId, 36 | createCommentInput.body, 37 | user, 38 | ); 39 | } 40 | 41 | @Mutation(() => Comment, { description: "delete a comment " }) 42 | async deleteComment( 43 | @Args("input") deleteCommentInput: DeleteCommentInput, 44 | @CurrentUser() user, 45 | ): Promise { 46 | return this.commentService.deleteComment( 47 | deleteCommentInput.commentId, 48 | user, 49 | deleteCommentInput.articleId, 50 | ); 51 | } 52 | 53 | @ResolveField(() => User) 54 | async author(@Parent() comment: Comment): Promise { 55 | return this.articleLoader.batchCommentAuthors.load(comment.author._id); 56 | } 57 | 58 | @Subscription(() => Comment) 59 | async commentAdded() { 60 | return this.pubSub.asyncIterator(COMMENT_ADDED_EVENT); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/articles/resolvers/favorite.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Args, 3 | Mutation, 4 | Parent, 5 | ResolveField, 6 | Resolver, 7 | } from "@nestjs/graphql"; 8 | import { ArticleIdInput } from "../dto/article/article-id.input"; 9 | import { FavoriteService } from "../services/favorite.service"; 10 | import { CurrentUser } from "../../auth/decorators"; 11 | import { User } from "../../users/users.schema"; 12 | import { Success } from "../dto/success.type"; 13 | import { Article } from "../schemas/article.schema"; 14 | import { ArticleLoader } from "../loaders/article.loader"; 15 | 16 | @Resolver(() => Article) 17 | export class FavoriteResolver { 18 | constructor( 19 | private readonly favoriteService: FavoriteService, 20 | private articleLoader: ArticleLoader, 21 | ) {} 22 | 23 | @Mutation(() => Success, { description: "favorite an article" }) 24 | async favoriteArticle( 25 | @CurrentUser() currentUser: User, 26 | @Args("input") favoriteArticleInput: ArticleIdInput, 27 | ) { 28 | const response = await this.favoriteService.addArticleToFavorite( 29 | favoriteArticleInput.articleId, 30 | currentUser, 31 | ); 32 | return { success: response }; 33 | } 34 | 35 | @Mutation(() => Success, { description: "unFavorite an article" }) 36 | async unFavoriteArticle( 37 | @CurrentUser() currentUser: User, 38 | @Args("input") favoriteArticleInput: ArticleIdInput, 39 | ) { 40 | const response = await this.favoriteService.deleteArticleFromFavorite( 41 | favoriteArticleInput.articleId, 42 | currentUser, 43 | ); 44 | return { success: response }; 45 | } 46 | 47 | @ResolveField(() => Number) 48 | favoritesCount(@Parent() article: Article): number { 49 | return article.favoritedUsers?.length ?? 0; 50 | } 51 | 52 | @ResolveField(() => Boolean) 53 | favorited(@Parent() article: Article, @CurrentUser() user): Promise { 54 | return this.articleLoader.batchFavorited.load({ 55 | user: user, 56 | articleId: article._id, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/articles/schemas/article.schema.ts: -------------------------------------------------------------------------------- 1 | import { Field, HideField, Int, ObjectType } from "@nestjs/graphql"; 2 | import { BaseSchema } from "../../common/base.schema"; 3 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 4 | import { User } from "../../users/users.schema"; 5 | import * as mongoose from "mongoose"; 6 | import { Document } from "mongoose"; 7 | import { Comment } from "./comment.schema"; 8 | 9 | @ObjectType() 10 | @Schema({ timestamps: true, id: true }) 11 | export class Article extends BaseSchema { 12 | @Prop({ required: true, unique: true }) 13 | slug: string; 14 | 15 | @Prop({ required: true }) 16 | title: string; 17 | 18 | @Prop() 19 | description: string; 20 | 21 | @Prop({ required: true }) 22 | body: string; 23 | 24 | @Prop([String]) 25 | tags: string[]; 26 | 27 | @Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User" }) 28 | author: User; 29 | 30 | @Prop({ 31 | type: [{ type: mongoose.Schema.Types.ObjectId, ref: "User", unique: true }], 32 | }) 33 | @HideField() 34 | readonly favoritedUsers: User[]; 35 | 36 | @Field(() => Int) 37 | readonly favoritesCount: number; 38 | 39 | readonly favorited: boolean; 40 | 41 | @Prop({ 42 | type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Comment" }], 43 | }) 44 | readonly comments: Comment[]; 45 | } 46 | 47 | export type ArticleDocument = Article & Document; 48 | 49 | export const ArticleSchema = SchemaFactory.createForClass(Article); 50 | -------------------------------------------------------------------------------- /src/articles/schemas/comment.schema.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "../../common/base.schema"; 2 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 3 | import { Document } from "mongoose"; 4 | import { ObjectType } from "@nestjs/graphql"; 5 | import { User } from "../../users/users.schema"; 6 | import * as mongoose from "mongoose"; 7 | 8 | @Schema({ timestamps: true }) 9 | @ObjectType() 10 | export class Comment extends BaseSchema { 11 | @Prop() 12 | body: string; 13 | 14 | @Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User" }) 15 | author: User; 16 | } 17 | 18 | export type CommentDocument = Comment & Document; 19 | 20 | export const CommentSchema = SchemaFactory.createForClass(Comment); 21 | -------------------------------------------------------------------------------- /src/articles/services/articles.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { CreateArticleInput } from "../dto/article/create-article.input"; 3 | import { UpdateArticleInput } from "../dto/article/update-article.input"; 4 | import { CrudService } from "../../common/services/crud.service"; 5 | import { Article, ArticleDocument } from "../schemas/article.schema"; 6 | import { User } from "../../users/users.schema"; 7 | import { InjectModel } from "@nestjs/mongoose"; 8 | import { Model, ObjectId } from "mongoose"; 9 | import { Comment } from "../schemas/comment.schema"; 10 | import { PUB_SUB } from "../../common/constants"; 11 | import { PubSub } from "graphql-subscriptions"; 12 | import { ARTICLE_ADDED_EVENT } from "../subscription-constants"; 13 | 14 | @Injectable() 15 | export class ArticlesService extends CrudService< 16 | Article, 17 | CreateArticleInput, 18 | UpdateArticleInput 19 | > { 20 | constructor( 21 | @InjectModel(Article.name) 22 | private readonly articleModel: Model, 23 | @Inject(PUB_SUB) private readonly pubSub: PubSub, 24 | ) { 25 | super(articleModel); 26 | } 27 | 28 | async createWithUser( 29 | createDto: CreateArticleInput, 30 | user: User, 31 | userRef = "author", 32 | ): Promise
{ 33 | const newArticle = await super.createWithUser(createDto, user, userRef); 34 | await this.pubSub.publish(ARTICLE_ADDED_EVENT, { 35 | articleAdded: newArticle, 36 | }); 37 | return newArticle; 38 | } 39 | 40 | async getCommentsForArticles(articleIds: ObjectId[]): Promise { 41 | const articlesWithComments = await this.articleModel 42 | .find({ 43 | _id: { $in: articleIds }, 44 | }) 45 | .populate("comments") 46 | .select("comments") 47 | .exec(); 48 | return articlesWithComments.map((article) => article.comments); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/articles/services/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { CrudService } from "../../common/services/crud.service"; 2 | import { Comment, CommentDocument } from "../schemas/comment.schema"; 3 | import { CreateCommentInput } from "../dto/comment/create-comment.input"; 4 | import { User } from "../../users/users.schema"; 5 | import { Inject, Injectable } from "@nestjs/common"; 6 | import { Model, ObjectId } from "mongoose"; 7 | import { InjectModel } from "@nestjs/mongoose"; 8 | import { ArticlesService } from "./articles.service"; 9 | import { Article, ArticleDocument } from "../schemas/article.schema"; 10 | import { PUB_SUB } from "../../common/constants"; 11 | import { PubSub } from "graphql-subscriptions"; 12 | import { COMMENT_ADDED_EVENT } from "../subscription-constants"; 13 | 14 | @Injectable() 15 | export class CommentService extends CrudService< 16 | Comment, 17 | CreateCommentInput, 18 | unknown 19 | > { 20 | constructor( 21 | @InjectModel(Comment.name) 22 | private readonly commentModel: Model, 23 | private readonly articlesService: ArticlesService, 24 | @InjectModel(Article.name) 25 | private readonly articleModel: Model, 26 | @Inject(PUB_SUB) private readonly pubSub: PubSub, 27 | ) { 28 | super(commentModel); 29 | } 30 | 31 | async addComment( 32 | articleId: ObjectId, 33 | body: string, 34 | user: User, 35 | ): Promise { 36 | const article = await this.articlesService.findOne({ _id: articleId }); 37 | const comment = new this.commentModel({ 38 | author: user, 39 | body, 40 | }); 41 | const newComment = await comment.save(); 42 | article.comments.push(newComment); 43 | await (article as ArticleDocument).save(); 44 | await this.pubSub.publish(COMMENT_ADDED_EVENT, { 45 | commentAdded: newComment, 46 | }); 47 | return newComment; 48 | } 49 | 50 | async deleteComment( 51 | commentId: ObjectId, 52 | user: User, 53 | articleId: ObjectId, 54 | ): Promise { 55 | await this.commentModel.deleteOne({ _id: commentId }); 56 | await this.articleModel.updateOne( 57 | { 58 | _id: articleId, 59 | }, 60 | { 61 | $pull: { 62 | comments: { author: user, _id: commentId }, 63 | }, 64 | }, 65 | ); 66 | return true; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/articles/services/favorite.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { UsersService } from "../../users/users.service"; 2 | import { Test, TestingModule } from "@nestjs/testing"; 3 | import { UsersModule } from "../../users/users.module"; 4 | import { 5 | closeMongoConnection, 6 | MongooseTestModule, 7 | } from "../../common/mongoose-testing.module"; 8 | import { ConfigModule } from "@nestjs/config"; 9 | import { User } from "../../users/users.schema"; 10 | import { Article } from "../schemas/article.schema"; 11 | import { ArticlesModule } from "../articles.module"; 12 | import { ArticlesService } from "./articles.service"; 13 | import { FavoriteService } from "./favorite.service"; 14 | 15 | describe("FavoriteService", () => { 16 | let userService: UsersService; 17 | let user: User; 18 | let article: Article; 19 | let articleService: ArticlesService; 20 | let favoriteService: FavoriteService; 21 | 22 | beforeEach(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | providers: [], 25 | imports: [ 26 | UsersModule, 27 | MongooseTestModule(), 28 | ArticlesModule, 29 | ConfigModule.forRoot({ isGlobal: true }), 30 | ], 31 | }).compile(); 32 | 33 | userService = module.get(UsersService); 34 | articleService = module.get(ArticlesService); 35 | favoriteService = module.get(FavoriteService); 36 | 37 | user = await userService.create({ 38 | confirmPassword: "1234", 39 | password: "1234", 40 | email: "test@email.com", 41 | username: "test", 42 | }); 43 | article = await articleService.createWithUser( 44 | { 45 | body: "test body", 46 | description: "Test desc", 47 | slug: "test-article", 48 | title: "hello", 49 | tags: ["asdasd", "ASdas"], 50 | }, 51 | user, 52 | ); 53 | }); 54 | 55 | afterEach(async () => { 56 | await articleService.delete({ _id: article._id }); 57 | await userService.delete({ _id: user._id }); 58 | }); 59 | 60 | afterAll(async () => { 61 | await closeMongoConnection(); 62 | }); 63 | 64 | it("should favorite an article", async () => { 65 | const res = await favoriteService.addArticleToFavorite(article._id, user); 66 | expect(res).toBeTruthy(); 67 | const updatedArticle = await articleService.findOne({ _id: article._id }); 68 | expect(updatedArticle.favoritedUsers[0]._id).toEqual(user._id); 69 | }); 70 | 71 | it("should be able to delete a favorite article", async () => { 72 | await favoriteService.addArticleToFavorite(article._id, user); 73 | const res = await favoriteService.deleteArticleFromFavorite( 74 | article._id, 75 | user, 76 | ); 77 | expect(res).toBeTruthy(); 78 | const updatedArticle = await articleService.findOne({ _id: article._id }); 79 | expect(updatedArticle.favoritedUsers.length).toEqual(0); 80 | }); 81 | 82 | it("should return if article favorite for a group of articles", async () => { 83 | await favoriteService.addArticleToFavorite(article._id, user); 84 | const favorited = await favoriteService.isArticlesFavoriteByUser(user, [ 85 | article._id, 86 | ]); 87 | expect(Array.isArray(favorited)).toBeTruthy(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/articles/services/favorite.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from "@nestjs/common"; 2 | import { InjectModel } from "@nestjs/mongoose"; 3 | import { Article } from "../schemas/article.schema"; 4 | import { Model, ObjectId } from "mongoose"; 5 | import { User } from "../../users/users.schema"; 6 | import { ArticlesService } from "./articles.service"; 7 | 8 | @Injectable() 9 | export class FavoriteService { 10 | constructor( 11 | @InjectModel(Article.name) private articleModel: Model
, 12 | @InjectModel(User.name) private userModel: Model, 13 | private readonly articlesService: ArticlesService, 14 | ) {} 15 | async addArticleToFavorite(articleId: ObjectId, user: User) { 16 | const article = await this.articleModel.findOne({ _id: articleId }); 17 | if (!article) { 18 | throw new NotFoundException("not found"); 19 | } 20 | article.favoritedUsers.push(user); 21 | await article.save(); 22 | return true; 23 | } 24 | 25 | async deleteArticleFromFavorite(articleId: ObjectId, user: User) { 26 | await this.articleModel 27 | .updateOne( 28 | { 29 | _id: articleId, 30 | }, 31 | { 32 | $pull: { favoritedUsers: user._id }, 33 | }, 34 | ) 35 | .exec(); 36 | return true; 37 | } 38 | 39 | async isArticlesFavoriteByUser( 40 | user: User, 41 | articleIds: ObjectId[], 42 | ): Promise { 43 | const articles = await this.articlesService.findByIds(articleIds); 44 | return articles.map((article) => 45 | !article.favoritedUsers 46 | ? false 47 | : !!article.favoritedUsers.find( 48 | (u) => u._id.toString() === user._id.toString(), 49 | ), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/articles/subscription-constants.ts: -------------------------------------------------------------------------------- 1 | export const ARTICLE_ADDED_EVENT = "ARTICLE_ADDED"; 2 | 3 | export const COMMENT_ADDED_EVENT = "COMMENT_ADDED"; 4 | -------------------------------------------------------------------------------- /src/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { AuthGuard } from "@nestjs/passport"; 3 | import { GqlExecutionContext } from "@nestjs/graphql"; 4 | import { Reflector } from "@nestjs/core"; 5 | import { Observable } from "rxjs"; 6 | 7 | @Injectable() 8 | export class JwtAuthGuard extends AuthGuard("jwt") { 9 | constructor(private readonly reflector: Reflector) { 10 | super(); 11 | } 12 | 13 | getRequest(context: ExecutionContext) { 14 | const ctx = GqlExecutionContext.create(context); 15 | return ctx.getContext().req; 16 | } 17 | 18 | canActivate( 19 | context: ExecutionContext, 20 | ): boolean | Promise | Observable { 21 | const isPublic = this.reflector.get( 22 | "isPublic", 23 | context.getHandler(), 24 | ); 25 | if (isPublic) { 26 | return true; 27 | } 28 | return super.canActivate(context); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from "@nestjs/common"; 2 | import { AuthService } from "./auth.service"; 3 | import { UsersModule } from "../users/users.module"; 4 | import { JwtStrategy } from "./strategies/jwt.strategy"; 5 | import { JwtModule } from "@nestjs/jwt"; 6 | import { ConfigService } from "@nestjs/config"; 7 | import { AuthResolver } from "./auth.resolver"; 8 | import { JwtAuthGuard } from "./auth.guard"; 9 | 10 | @Module({ 11 | imports: [ 12 | forwardRef(() => UsersModule), 13 | JwtModule.registerAsync({ 14 | inject: [ConfigService], 15 | useFactory: async (config: ConfigService) => ({ 16 | secret: config.get("SECRET_KEY"), 17 | signOptions: { expiresIn: "1d" }, 18 | }), 19 | }), 20 | ], 21 | exports: [AuthService], 22 | providers: [AuthService, JwtStrategy, AuthResolver, JwtAuthGuard], 23 | }) 24 | export class AuthModule {} 25 | -------------------------------------------------------------------------------- /src/auth/auth.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { AuthResolver } from "./auth.resolver"; 3 | import { MongooseTestModule } from "../common/mongoose-testing.module"; 4 | import { UsersService } from "../users/users.service"; 5 | import { UsersModule } from "../users/users.module"; 6 | import { ConfigModule } from "@nestjs/config"; 7 | import { AuthModule } from "./auth.module"; 8 | import { NotFoundException } from "@nestjs/common"; 9 | 10 | const EMAIL = "ramzi@gmail.com"; 11 | const PASSWORD = "ramzi"; 12 | 13 | describe("AuthResolver", () => { 14 | let resolver: AuthResolver; 15 | let userService: UsersService; 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | providers: [], 20 | imports: [ 21 | AuthModule, 22 | UsersModule, 23 | MongooseTestModule(), 24 | ConfigModule.forRoot({ isGlobal: true }), 25 | ], 26 | }).compile(); 27 | 28 | resolver = module.get(AuthResolver); 29 | userService = module.get(UsersService); 30 | }); 31 | 32 | it("should be defined", () => { 33 | expect(resolver).toBeDefined(); 34 | }); 35 | it("should return a valid token", async () => { 36 | const user = await userService.create({ 37 | confirmPassword: PASSWORD, 38 | password: PASSWORD, 39 | email: EMAIL, 40 | username: "ramzi", 41 | }); 42 | const { success, token } = await resolver.login({ 43 | email: EMAIL, 44 | password: PASSWORD, 45 | }); 46 | expect(success).toBeTruthy(); 47 | expect(token).toBeTruthy(); 48 | await userService.delete({ _id: user.id }); 49 | }); 50 | it("should throw exception when not logged in", async () => { 51 | await expect(async () => { 52 | await resolver.login({ 53 | email: EMAIL, 54 | password: PASSWORD, 55 | }); 56 | }).rejects.toThrow(NotFoundException); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/auth/auth.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 2 | import { JWTTokenResponseType, LoginInputType } from "./dto/login.dto"; 3 | import { AuthService } from "./auth.service"; 4 | import { CurrentUser, Public } from "./decorators"; 5 | import { User } from "../users/users.schema"; 6 | 7 | @Resolver(() => JWTTokenResponseType) 8 | export class AuthResolver { 9 | constructor(private readonly authService: AuthService) {} 10 | 11 | @Mutation(() => JWTTokenResponseType, { 12 | description: "login using email/password to obtain a JWT token", 13 | }) 14 | @Public() 15 | async login( 16 | @Args("input") { email, password }: LoginInputType, 17 | ): Promise { 18 | return this.authService.loginUser(email, password); 19 | } 20 | 21 | @Query(() => User, { description: "returns current logged in user" }) 22 | async currentUser(@CurrentUser() currentUser: User): Promise { 23 | return currentUser; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { AuthService } from "./auth.service"; 3 | import { UsersService } from "../users/users.service"; 4 | import { AuthModule } from "./auth.module"; 5 | import { 6 | closeMongoConnection, 7 | MongooseTestModule, 8 | } from "../common/mongoose-testing.module"; 9 | 10 | let authService: AuthService; 11 | 12 | const EMAIL = "Ramzi@test.com"; 13 | const PASSWORD = "password"; 14 | 15 | describe("AuthService", () => { 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | providers: [], 19 | imports: [AuthModule, MongooseTestModule()], 20 | }).compile(); 21 | 22 | authService = module.get(AuthService); 23 | const userService = module.get(UsersService); 24 | await userService.create({ 25 | username: "test", 26 | email: EMAIL, 27 | password: PASSWORD, 28 | confirmPassword: PASSWORD, 29 | }); 30 | }); 31 | 32 | it("should return true if log in success", async () => { 33 | const isLogged = await authService.validateUser(EMAIL, PASSWORD); 34 | expect(isLogged).toBeTruthy(); 35 | }); 36 | 37 | it("should return false if log in failed", async () => { 38 | const isLogged = await authService.validateUser(EMAIL, "q343"); 39 | expect(isLogged).toBeFalsy(); 40 | }); 41 | 42 | afterAll(() => closeMongoConnection()); 43 | }); 44 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable } from "@nestjs/common"; 2 | import * as bcrypt from "bcrypt"; 3 | import { UsersService } from "../users/users.service"; 4 | import { User } from "../users/users.schema"; 5 | import { JwtService } from "@nestjs/jwt"; 6 | import { GraphQLError } from "graphql"; 7 | import { JWTTokenResponseType } from "./dto/login.dto"; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor( 12 | @Inject(forwardRef(() => UsersService)) 13 | private readonly userService: UsersService, 14 | private readonly jwtService: JwtService, 15 | ) {} 16 | 17 | async createPassword(password: string): Promise { 18 | const salt = await bcrypt.genSalt(); 19 | return await bcrypt.hash(password, salt); 20 | } 21 | 22 | async verifyPassword( 23 | password: string, 24 | hashedPassword: string, 25 | ): Promise { 26 | return await bcrypt.compare(password, hashedPassword); 27 | } 28 | 29 | async validateUser(email: string, password: string): Promise { 30 | const user = await this.userService.findByEmail(email); 31 | const isPasswordVerified = await this.verifyPassword( 32 | password, 33 | user.password, 34 | ); 35 | if (isPasswordVerified) { 36 | return user; 37 | } 38 | return null; 39 | } 40 | 41 | async loginUser( 42 | email: string, 43 | password: string, 44 | ): Promise { 45 | const user = await this.validateUser(email, password); 46 | if (!user) { 47 | throw new GraphQLError("User Credentials are wrong !"); 48 | } 49 | try { 50 | const token = await this.jwtService.signAsync( 51 | { 52 | email: user.email, 53 | id: user.id, 54 | username: user.username, 55 | }, 56 | {}, 57 | ); 58 | return { token, success: true }; 59 | } catch (e) { 60 | return { token: null, success: false }; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/auth/decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | SetMetadata, 5 | } from "@nestjs/common"; 6 | import { GqlExecutionContext } from "@nestjs/graphql"; 7 | 8 | export const Public = () => SetMetadata("isPublic", true); 9 | 10 | export const CurrentUser = createParamDecorator( 11 | (data, ctx: ExecutionContext) => { 12 | return GqlExecutionContext.create(ctx).getContext().req.user; 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { InputType, ObjectType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class LoginInputType { 5 | email: string; 6 | password: string; 7 | } 8 | 9 | @ObjectType() 10 | export class JWTTokenResponseType { 11 | token?: string; 12 | success: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /src/auth/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JWTPayload { 2 | id: string; 3 | username: string; 4 | email: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { ExtractJwt, Strategy } from "passport-jwt"; 4 | import { AuthService } from "../auth.service"; 5 | import { ConfigService } from "@nestjs/config"; 6 | import { JWTPayload } from "../jwt-payload.interface"; 7 | import { UsersService } from "../../users/users.service"; 8 | import { User } from "../../users/users.schema"; 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor( 13 | private readonly authService: AuthService, 14 | private readonly configService: ConfigService, 15 | private readonly userService: UsersService, 16 | ) { 17 | super({ 18 | usernameField: "email", 19 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 20 | ignoreExpiration: false, 21 | secretOrKey: configService.get("SECRET_KEY"), 22 | }); 23 | } 24 | 25 | async validate(payload: JWTPayload): Promise { 26 | return this.userService.findOne({ _id: payload.id }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/common/base.schema.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongoose"; 2 | import { HideField, ObjectType } from "@nestjs/graphql"; 3 | 4 | @ObjectType({ isAbstract: true }) 5 | export class BaseSchema { 6 | readonly id?: string; 7 | 8 | @HideField() 9 | readonly _id?: ObjectId; 10 | 11 | readonly createdAt: Date; 12 | 13 | readonly updatedAt: Date; 14 | } 15 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { PUB_SUB } from "./constants"; 3 | import { PubSub } from "graphql-subscriptions"; 4 | import { FileUploadService } from "./services/file-upload.service"; 5 | import { ImagesController } from "./images.controller"; 6 | 7 | const pubSubProvider = { 8 | provide: PUB_SUB, 9 | useValue: new PubSub(), 10 | }; 11 | 12 | @Module({ 13 | providers: [pubSubProvider, FileUploadService], 14 | controllers: [ImagesController], 15 | exports: [pubSubProvider, FileUploadService], 16 | }) 17 | export class CommonModule {} 18 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const PUB_SUB = "PUB_SUB"; 2 | -------------------------------------------------------------------------------- /src/common/images.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Res } from "@nestjs/common"; 2 | import { Response } from "express"; 3 | import { FileUploadService } from "./services/file-upload.service"; 4 | import { Public } from "../auth/decorators"; 5 | 6 | @Controller("images") 7 | export class ImagesController { 8 | constructor(private readonly fileUploadService: FileUploadService) {} 9 | 10 | @Get(":name") 11 | @Public() 12 | getImage(@Param("name") name: string, @Res() res: Response) { 13 | res.sendFile(this.fileUploadService.getFilePath(name)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/mapper.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongoose"; 2 | import { BaseSchema } from "./base.schema"; 3 | 4 | export class Mapper { 5 | public mapObjectsToId(objects: T[], ids: ObjectId[]): T[] { 6 | const map = new Map(objects.map((obj) => [obj.id, obj])); 7 | return ids.map((id) => map.get(id.toString())); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/common/mongo-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Catch, ExceptionFilter } from "@nestjs/common"; 2 | import { MongoError } from "mongodb"; 3 | 4 | @Catch(MongoError) 5 | export class MongoErrorFilter implements ExceptionFilter { 6 | catch(exception: MongoError) { 7 | return new BadRequestException({ 8 | code: exception.code, 9 | msg: exception.message, 10 | }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/mongoose-testing.module.ts: -------------------------------------------------------------------------------- 1 | import { MongooseModule, MongooseModuleOptions } from "@nestjs/mongoose"; 2 | import { MongoMemoryServer } from "mongodb-memory-server"; 3 | 4 | let mongod: MongoMemoryServer; 5 | 6 | export const MongooseTestModule = (options: MongooseModuleOptions = {}) => 7 | MongooseModule.forRootAsync({ 8 | useFactory: async () => { 9 | mongod = new MongoMemoryServer(); 10 | const mongoUri = await mongod.getUri(); 11 | return { 12 | uri: mongoUri, 13 | ...options, 14 | }; 15 | }, 16 | }); 17 | 18 | export const closeMongoConnection = async () => { 19 | if (mongod) await mongod.stop(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/common/pagination/pagination.args.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, Int } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class PaginationArgs { 5 | @Field(() => Int) 6 | page?: number; 7 | 8 | @Field(() => Int) 9 | pageSize?: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/common/pagination/pagination.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from "@nestjs/graphql"; 2 | import { Type } from "@nestjs/common"; 3 | 4 | @ObjectType() 5 | class PaginationInfo { 6 | totalCount: number; 7 | hasPreviousPage: boolean; 8 | hasNextPage: boolean; 9 | page: number; 10 | totalPages: number; 11 | nextPage: number; 12 | prevPage: number; 13 | } 14 | 15 | export class Pagination extends PaginationInfo { 16 | results: T[]; 17 | } 18 | 19 | // ugly hack to make generic works 20 | export const Paginated = (classRef: Type): any => { 21 | @ObjectType() 22 | class PaginationType extends PaginationInfo { 23 | @Field(() => [classRef]) 24 | results: T[]; 25 | } 26 | 27 | return PaginationType; 28 | }; 29 | -------------------------------------------------------------------------------- /src/common/services/crud.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FilterQuery, 3 | Model, 4 | ObjectId, 5 | Document, 6 | QueryOptions, 7 | PaginateModel, 8 | } from "mongoose"; 9 | import { NotFoundException } from "@nestjs/common"; 10 | import { Pagination } from "../pagination/pagination.type"; 11 | import { User } from "../../users/users.schema"; 12 | import { BaseSchema } from "../base.schema"; 13 | 14 | type TDocument = T & Document; 15 | type TPaginatedDocument = PaginateModel>; 16 | 17 | const PAGE_SIZE = 10; 18 | 19 | export class CrudService { 20 | private paginatedModel: TPaginatedDocument; 21 | 22 | constructor(private readonly model: Model>) { 23 | this.paginatedModel = model as TPaginatedDocument; 24 | } 25 | 26 | async create(createDto: CreateDTO): Promise { 27 | const createdObject = new this.model(createDto); 28 | return createdObject.save(); 29 | } 30 | 31 | async createWithUser( 32 | createDto: CreateDTO, 33 | user: User, 34 | userRef: string, 35 | ): Promise { 36 | const createdObject = new this.model({ 37 | ...createDto, 38 | [userRef]: user.id, 39 | }); 40 | return createdObject.save(); 41 | } 42 | 43 | async update(id: ObjectId, updateDto: UpdateDTO): Promise { 44 | const result = await this.model.findByIdAndUpdate(id, updateDto).exec(); 45 | if (!result) { 46 | throw new NotFoundException(result); 47 | } 48 | return result; 49 | } 50 | 51 | async delete(filter: FilterQuery>): Promise { 52 | const result = await this.model.deleteOne(filter).exec(); 53 | if (!result.ok) { 54 | throw new NotFoundException("not found"); 55 | } 56 | return true; 57 | } 58 | 59 | async findOne( 60 | filter: FilterQuery>, 61 | projection: any = null, 62 | options: QueryOptions = {}, 63 | ): Promise { 64 | const obj = await this.model.findOne(filter, projection, options).exec(); 65 | if (!obj) { 66 | throw new NotFoundException("not found"); 67 | } 68 | return obj; 69 | } 70 | 71 | protected async paginateQuery( 72 | query, 73 | options: QueryOptions, 74 | page: number, 75 | pageSize: number, 76 | ): Promise> { 77 | const results = await this.paginatedModel.paginate(query, { 78 | ...options, 79 | pagination: true, 80 | page: page, 81 | limit: pageSize, 82 | lean: true, 83 | }); 84 | return { 85 | hasNextPage: results.hasNextPage, 86 | hasPreviousPage: results.hasPrevPage, 87 | nextPage: results.nextPage, 88 | prevPage: results.prevPage, 89 | results: results.docs, 90 | totalCount: results.totalDocs, 91 | totalPages: results.totalPages, 92 | page: results.page, 93 | }; 94 | } 95 | 96 | async findAll( 97 | query: FilterQuery>, 98 | projection = null, 99 | options: QueryOptions, 100 | page: number, 101 | pageSize: number = PAGE_SIZE, 102 | ): Promise> { 103 | return this.paginateQuery( 104 | this.paginatedModel.find(query, projection, null), 105 | options, 106 | page, 107 | pageSize, 108 | ); 109 | } 110 | 111 | async findByIds(ids: ObjectId[]): Promise { 112 | // @ts-ignore 113 | return this.model.find({ _id: { $in: ids } }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/common/services/file-upload.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { ConfigService } from "@nestjs/config"; 3 | import { FileUpload } from "graphql-upload"; 4 | import { extname, basename, join, resolve } from "path"; 5 | import { v4 } from "uuid"; 6 | import { mkdirSync, existsSync, createWriteStream, rmSync } from "fs"; 7 | 8 | @Injectable() 9 | export class FileUploadService { 10 | private readonly uploadPath: string; 11 | 12 | constructor(private readonly configService: ConfigService) { 13 | this.uploadPath = configService.get("UPLOAD_PATH"); 14 | } 15 | 16 | private createUploadDirectoryIfNeeded() { 17 | if (!existsSync(this.uploadPath)) { 18 | mkdirSync(this.uploadPath, { recursive: true }); 19 | } 20 | } 21 | 22 | async saveFile(file: FileUpload): Promise { 23 | const { createReadStream, filename } = await file; 24 | const ext = extname(filename); 25 | const name = basename(filename, ext); 26 | const newFileName = `${name}_${v4()}${ext}`; 27 | const filePath = join(this.uploadPath, newFileName); 28 | this.createUploadDirectoryIfNeeded(); 29 | return new Promise(async (resolve, reject) => 30 | createReadStream() 31 | .pipe(createWriteStream(filePath)) 32 | .on("finish", () => resolve(newFileName)) 33 | .on("error", () => reject()), 34 | ); 35 | } 36 | 37 | getFilePath(fileName: string): string { 38 | return resolve(join(this.uploadPath, fileName)); 39 | } 40 | 41 | deleteFile(fileName: string) { 42 | rmSync(this.getFilePath(fileName)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "source-map-support/register"; 2 | import { NestFactory, Reflector } from "@nestjs/core"; 3 | import { AppModule } from "./app.module"; 4 | import { ConfigService } from "@nestjs/config"; 5 | import { ValidationPipe } from "@nestjs/common"; 6 | import { MongoErrorFilter } from "./common/mongo-error.filter"; 7 | import { JwtAuthGuard } from "./auth/auth.guard"; 8 | import * as mongoose from "mongoose"; 9 | import { graphqlUploadExpress } from "graphql-upload"; 10 | import { NestExpressApplication } from "@nestjs/platform-express"; 11 | import { join } from "path"; 12 | 13 | async function bootstrap() { 14 | const app = await NestFactory.create(AppModule); 15 | const configService = app.get(ConfigService); 16 | app.useStaticAssets(join(__dirname, "..", "static")); 17 | app.useGlobalPipes(new ValidationPipe()); 18 | app.useGlobalFilters(new MongoErrorFilter()); 19 | app.use( 20 | graphqlUploadExpress({ 21 | maxFieldSize: 500 * 1000, 22 | maxFiles: 5, 23 | maxFileSize: 500 * 1000, 24 | }), 25 | ); 26 | const reflector = app.get(Reflector); 27 | app.useGlobalGuards(new JwtAuthGuard(reflector)); 28 | mongoose.set("debug", true); 29 | const server = await app.listen(configService.get("SERVER_PORT")); 30 | server.setTimeout(60 * 1000); 31 | server.keepAliveTimeout = 60 * 1000; 32 | } 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /src/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type User { 6 | id: String 7 | createdAt: DateTime! 8 | updatedAt: DateTime! 9 | username: String! 10 | email: String! 11 | bio: String 12 | image: String 13 | isActive: Boolean! 14 | } 15 | 16 | """ 17 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. 18 | """ 19 | scalar DateTime 20 | 21 | type JWTTokenResponseType { 22 | token: String 23 | success: Boolean! 24 | } 25 | 26 | type Comment { 27 | id: String 28 | createdAt: DateTime! 29 | updatedAt: DateTime! 30 | body: String! 31 | author: User! 32 | } 33 | 34 | type Article { 35 | id: String 36 | createdAt: DateTime! 37 | updatedAt: DateTime! 38 | favoritesCount: Int! 39 | slug: String! 40 | title: String! 41 | description: String! 42 | body: String! 43 | tags: [String!]! 44 | author: User! 45 | favorited: Boolean! 46 | comments: [Comment!]! 47 | } 48 | 49 | type PaginatedArticle { 50 | totalCount: Float! 51 | hasPreviousPage: Boolean! 52 | hasNextPage: Boolean! 53 | page: Float! 54 | totalPages: Float! 55 | nextPage: Float! 56 | prevPage: Float! 57 | results: [Article!]! 58 | } 59 | 60 | type Success { 61 | success: Boolean! 62 | } 63 | 64 | type Query { 65 | """returns current logged in user""" 66 | currentUser: User! 67 | 68 | """get all articles""" 69 | allArticles(pageInfo: PaginationArgs): PaginatedArticle! 70 | 71 | """get article by filter""" 72 | getArticle(filter: ArticleFilterInput!): Article! 73 | } 74 | 75 | input PaginationArgs { 76 | page: Int 77 | pageSize: Int 78 | } 79 | 80 | input ArticleFilterInput { 81 | id: String 82 | slug: String 83 | } 84 | 85 | type Mutation { 86 | """create a new user""" 87 | createUser(input: CreateUserInput!): User! 88 | 89 | """update an existing user""" 90 | updateUser(input: UpdateUserInput!): User! 91 | 92 | """login using email/password to obtain a JWT token""" 93 | login(input: LoginInputType!): JWTTokenResponseType! 94 | 95 | """create a new article""" 96 | createArticle(input: CreateArticleInput!): Article! 97 | 98 | """update an existing article""" 99 | updateArticle(input: UpdateArticleInput!): Article! 100 | 101 | """delete an existing article""" 102 | deleteArticle(input: ArticleIdInput!): Success! 103 | 104 | """favorite an article""" 105 | favoriteArticle(input: ArticleIdInput!): Success! 106 | 107 | """unFavorite an article""" 108 | unFavoriteArticle(input: ArticleIdInput!): Success! 109 | 110 | """add a comment to existing article""" 111 | addComment(input: CreateCommentInput!): Comment! 112 | 113 | """delete a comment """ 114 | deleteComment(input: DeleteCommentInput!): Comment! 115 | } 116 | 117 | input CreateUserInput { 118 | username: String! 119 | email: String! 120 | bio: String 121 | image: Upload 122 | confirmPassword: String! 123 | password: String! 124 | } 125 | 126 | """The `Upload` scalar type represents a file upload.""" 127 | scalar Upload 128 | 129 | input UpdateUserInput { 130 | username: String 131 | email: String 132 | bio: String 133 | image: Upload 134 | } 135 | 136 | input LoginInputType { 137 | email: String! 138 | password: String! 139 | } 140 | 141 | input CreateArticleInput { 142 | slug: String! 143 | title: String! 144 | description: String! 145 | body: String! 146 | tags: [String!]! 147 | } 148 | 149 | input UpdateArticleInput { 150 | slug: String 151 | title: String 152 | description: String 153 | body: String 154 | tags: [String!] 155 | id: ID! 156 | } 157 | 158 | input ArticleIdInput { 159 | articleId: String! 160 | } 161 | 162 | input CreateCommentInput { 163 | articleId: String! 164 | body: String! 165 | } 166 | 167 | input DeleteCommentInput { 168 | commentId: String! 169 | articleId: String! 170 | } 171 | 172 | type Subscription { 173 | articleAdded: Article! 174 | commentAdded: Comment! 175 | } 176 | -------------------------------------------------------------------------------- /src/users/dto/create-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, OmitType } from "@nestjs/graphql"; 2 | import { IsNotEmpty, MinLength } from "class-validator"; 3 | import { User } from "../users.schema"; 4 | import { FileUpload, GraphQLUpload } from "graphql-upload"; 5 | 6 | @InputType() 7 | export class CreateUserInput extends OmitType( 8 | User, 9 | ["isActive", "id", "createdAt", "updatedAt", "image"], 10 | InputType, 11 | ) { 12 | @IsNotEmpty() 13 | confirmPassword: string; 14 | 15 | @IsNotEmpty() 16 | @MinLength(5) 17 | password: string; 18 | 19 | @Field(() => GraphQLUpload) 20 | readonly image?: FileUpload; 21 | } 22 | -------------------------------------------------------------------------------- /src/users/dto/update-user.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, OmitType, PartialType } from "@nestjs/graphql"; 2 | import { CreateUserInput } from "./create-user.input"; 3 | 4 | @InputType() 5 | export class UpdateUserInput extends PartialType( 6 | OmitType(CreateUserInput, ["password", "confirmPassword"]), 7 | InputType, 8 | ) {} 9 | -------------------------------------------------------------------------------- /src/users/pipes/validate-password.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidatePasswordPipe } from "./validate-password.pipe"; 2 | 3 | describe("ValidatePasswosrdPipe", () => { 4 | it("should be defined", () => { 5 | expect(new ValidatePasswordPipe()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/users/pipes/validate-password.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; 2 | import { CreateUserInput } from "../dto/create-user.input"; 3 | 4 | @Injectable() 5 | export class ValidatePasswordPipe implements PipeTransform { 6 | transform(value: CreateUserInput) { 7 | if (value.password !== value.confirmPassword) { 8 | throw new BadRequestException("passwords dont match"); 9 | } 10 | return value; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from "@nestjs/common"; 2 | import { MongooseModule } from "@nestjs/mongoose"; 3 | import { User, UserSchema } from "./users.schema"; 4 | import { UsersResolver } from "./users.resolver"; 5 | import { UsersService } from "./users.service"; 6 | import { AuthModule } from "../auth/auth.module"; 7 | import { CommonModule } from "../common/common.module"; 8 | 9 | @Module({ 10 | imports: [ 11 | MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), 12 | forwardRef(() => AuthModule), 13 | CommonModule, 14 | ], 15 | providers: [UsersResolver, UsersService], 16 | exports: [UsersService, MongooseModule], 17 | }) 18 | export class UsersModule {} 19 | -------------------------------------------------------------------------------- /src/users/users.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { UsersResolver } from "./users.resolver"; 3 | 4 | describe("UsersResolver", () => { 5 | let resolver: UsersResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(UsersResolver); 13 | }); 14 | 15 | it("should be defined", () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/users.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Resolver } from "@nestjs/graphql"; 2 | import { UsersService } from "./users.service"; 3 | import { ValidatePasswordPipe } from "./pipes/validate-password.pipe"; 4 | import { CreateUserInput } from "./dto/create-user.input"; 5 | import { UpdateUserInput } from "./dto/update-user.input"; 6 | import { User } from "./users.schema"; 7 | import { CurrentUser, Public } from "../auth/decorators"; 8 | 9 | @Resolver(() => User) 10 | export class UsersResolver { 11 | constructor(private readonly userService: UsersService) {} 12 | 13 | @Mutation(() => User, { description: "create a new user" }) 14 | @Public() 15 | async createUser( 16 | @Args("input", new ValidatePasswordPipe()) 17 | createUserInputType: CreateUserInput, 18 | ) { 19 | return this.userService.create(createUserInputType); 20 | } 21 | 22 | @Mutation(() => User, { description: "update an existing user" }) 23 | async updateUser( 24 | @Args("input") 25 | updateUserInputType: UpdateUserInput, 26 | @CurrentUser() currentUser: User, 27 | ): Promise { 28 | return this.userService.update(currentUser._id, updateUserInputType); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/users/users.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Document } from "mongoose"; 3 | import { ObjectType } from "@nestjs/graphql"; 4 | import { HideField } from "@nestjs/graphql"; 5 | import { BaseSchema } from "../common/base.schema"; 6 | 7 | @Schema({ timestamps: true }) 8 | @ObjectType() 9 | export class User extends BaseSchema { 10 | @Prop({ unique: true, required: true }) 11 | username: string; 12 | 13 | @Prop({ unique: true, required: true }) 14 | email: string; 15 | 16 | @HideField() 17 | @Prop({ required: true }) 18 | password: string; 19 | 20 | @Prop({ required: false, default: null }) 21 | bio?: string; 22 | 23 | @Prop({ required: false, default: null }) 24 | image?: string; 25 | 26 | @Prop({ default: false }) 27 | isActive: boolean; 28 | } 29 | 30 | export type UserDocument = User & Document; 31 | 32 | export const UserSchema = SchemaFactory.createForClass(User); 33 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { CrudService } from "../common/services/crud.service"; 2 | import { User, UserDocument } from "./users.schema"; 3 | import { Model } from "mongoose"; 4 | import { InjectModel } from "@nestjs/mongoose"; 5 | import { AuthService } from "../auth/auth.service"; 6 | import { forwardRef, Inject, Injectable } from "@nestjs/common"; 7 | import { CreateUserInput } from "./dto/create-user.input"; 8 | import { UpdateUserInput } from "./dto/update-user.input"; 9 | import { FileUploadService } from "../common/services/file-upload.service"; 10 | 11 | @Injectable() 12 | export class UsersService extends CrudService< 13 | User, 14 | CreateUserInput, 15 | UpdateUserInput 16 | > { 17 | constructor( 18 | @InjectModel(User.name) private readonly userModel: Model, 19 | @Inject(forwardRef(() => AuthService)) 20 | private readonly authService: AuthService, 21 | private readonly fileUploadService: FileUploadService, 22 | ) { 23 | super(userModel); 24 | } 25 | 26 | async create(createDto: CreateUserInput): Promise { 27 | createDto.password = await this.authService.createPassword( 28 | createDto.password, 29 | ); 30 | delete createDto.confirmPassword; 31 | const newUser = { ...createDto, isActive: true } as any; 32 | 33 | if (createDto.image) { 34 | newUser.image = await this.fileUploadService.saveFile(createDto.image); 35 | } 36 | 37 | const user = new this.userModel(newUser); 38 | try { 39 | await user.save(); 40 | } catch (e) { 41 | if (newUser.image) { 42 | this.fileUploadService.deleteFile(newUser.image); 43 | } 44 | } 45 | return user; 46 | } 47 | 48 | async findByEmail(email: string): Promise { 49 | return await super.findOne({ email }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig.json", 3 | "exclude": ["node_modules", 4 | "test", "dist", "**/*spec.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /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 | }, 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | --------------------------------------------------------------------------------