├── .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 | 
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 |
--------------------------------------------------------------------------------