├── .env.example ├── .gitignore ├── .prettierrc ├── README.md ├── index.html ├── nest-cli.json ├── package.json ├── src ├── app.gateway.ts ├── app.module.ts ├── comment │ ├── comment.controller.spec.ts │ ├── comment.controller.ts │ ├── comment.entity.ts │ ├── comment.module.ts │ ├── comment.resolver.spec.ts │ ├── comment.resolver.ts │ ├── comment.service.spec.ts │ ├── comment.service.ts │ └── dto │ │ └── comment.dto.ts ├── idea │ ├── dto │ │ └── idea.dto.ts │ ├── idea.controller.spec.ts │ ├── idea.controller.ts │ ├── idea.entity.ts │ ├── idea.module.ts │ ├── idea.resolver.spec.ts │ ├── idea.resolver.ts │ ├── idea.service.spec.ts │ └── idea.service.ts ├── main.ts ├── shared │ ├── auth.guard.ts │ ├── http-error.filter.ts │ ├── logging.interceptor.ts │ ├── validation.pipe.ts │ └── votes.enum.ts └── user │ ├── Auth.response.ts │ ├── dto │ └── user.dto.ts │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.decorator.ts │ ├── user.entity.ts │ ├── user.module.ts │ ├── user.resolver.spec.ts │ ├── user.resolver.ts │ ├── user.service.spec.ts │ └── user.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST= 2 | POSTGRES_USERNAME= 3 | POSTGRES_PASSWORD= 4 | 5 | JWT_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | schema.gql 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | yarn.lock 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | This is a little example of how to use nest.js creating a REST and GraphQL API using Type-GraphQL. 4 | 5 | ## Tips 6 | 7 | Generate new module: 8 | 9 | - `nest g module MODULE_NAME` 10 | - `nest g controller CONTROLLER_NAME # This is for REST api` 11 | - `nest g service SERVICE_NAME` 12 | - `nest g resolver RESOLVER_NAME # This is for GraphQL api` 13 | 14 | Update all the packages: 15 | 16 | `yarn upgrade-interactive --latest` 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 17 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-graphql-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "rimraf dist && tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"", 12 | "start:debug": "tsc-watch -p tsconfig.build.json --onSuccess \"node --inspect-brk dist/main.js\"", 13 | "start:prod": "node dist/main.js", 14 | "lint": "tslint -p tsconfig.json -c tslint.json", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@nestjs/common": "^6.5.3", 23 | "@nestjs/core": "^6.5.3", 24 | "@nestjs/graphql": "^6.4.2", 25 | "@nestjs/platform-express": "^6.5.3", 26 | "@nestjs/platform-socket.io": "^6.5.3", 27 | "@nestjs/typeorm": "^6.1.3", 28 | "@nestjs/websockets": "^6.5.3", 29 | "apollo-server-express": "^2.7.2", 30 | "argon2": "^0.24.0", 31 | "class-transformer": "^0.2.3", 32 | "class-validator": "^0.9.1", 33 | "dotenv": "^8.0.0", 34 | "graphql": "^14.4.2", 35 | "jsonwebtoken": "^8.5.1", 36 | "pg": "^7.12.0", 37 | "reflect-metadata": "^0.1.12", 38 | "rimraf": "^2.6.2", 39 | "rxjs": "^6.3.3", 40 | "type-graphql": "^0.17.4", 41 | "typeorm": "^0.2.18" 42 | }, 43 | "devDependencies": { 44 | "@nestjs/testing": "6.5.3", 45 | "@types/argon2": "^0.15.0", 46 | "@types/dotenv": "^6.1.1", 47 | "@types/express": "4.17.0", 48 | "@types/graphql": "^14.2.3", 49 | "@types/jest": "24.0.15", 50 | "@types/jsonwebtoken": "^8.3.2", 51 | "@types/node": "^12.6.8", 52 | "@types/socket.io": "^2.1.2", 53 | "@types/supertest": "2.0.8", 54 | "jest": "24.8.0", 55 | "prettier": "1.18.2", 56 | "supertest": "4.0.2", 57 | "ts-jest": "24.0.2", 58 | "ts-node": "8.3.0", 59 | "tsc-watch": "2.2.1", 60 | "tsconfig-paths": "3.8.0", 61 | "tslint": "5.18.0", 62 | "typescript": "3.5.3" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": "src", 71 | "testRegex": ".spec.ts$", 72 | "transform": { 73 | "^.+\\.(t|j)s$": "ts-jest" 74 | }, 75 | "coverageDirectory": "../coverage", 76 | "testEnvironment": "node" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketGateway, 3 | WebSocketServer, 4 | SubscribeMessage, 5 | } from "@nestjs/websockets"; 6 | import { Logger } from "@nestjs/common"; 7 | import { Client, Server } from "socket.io"; 8 | 9 | @WebSocketGateway(4000) 10 | export class AppGateway { 11 | @WebSocketServer() 12 | wss: Server; 13 | 14 | private logger = new Logger("AppGateway"); 15 | 16 | @SubscribeMessage("client") 17 | onEvent(client: Client, data: string): string { 18 | this.logger.log("New client connected" + client); 19 | 20 | return "Successfully connected to server" + data; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; 2 | import { Module } from "@nestjs/common"; 3 | import { TypeOrmModule } from "@nestjs/typeorm"; 4 | import { GraphQLModule } from "@nestjs/graphql"; 5 | 6 | import { IdeaModule } from "./idea/idea.module"; 7 | import { HttpErrorFilter } from "./shared/http-error.filter"; 8 | import { LoggingInterceptor } from "./shared/logging.interceptor"; 9 | import { ValidationPipe } from "./shared/validation.pipe"; 10 | import { UserModule } from "./user/user.module"; 11 | import { CommentModule } from "./comment/comment.module"; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forRoot({ 16 | type: "postgres", 17 | host: process.env.POSTGRES_HOST, 18 | port: 5432, 19 | username: process.env.POSTGRES_USERNAME, 20 | password: process.env.POSTGRES_PASSWORD, 21 | database: "ideas", 22 | entities: [__dirname + "/**/*.entity{.ts,.js}"], 23 | synchronize: true, 24 | logging: true, 25 | }), 26 | GraphQLModule.forRoot({ 27 | playground: process.env.NODE_ENV !== "production", 28 | autoSchemaFile: "schema.gql", 29 | context: ({ req }) => ({ headers: req.headers }), 30 | }), 31 | IdeaModule, 32 | UserModule, 33 | CommentModule, 34 | ], 35 | providers: [ 36 | { 37 | provide: APP_FILTER, 38 | useClass: HttpErrorFilter, 39 | }, 40 | { 41 | provide: APP_INTERCEPTOR, 42 | useClass: LoggingInterceptor, 43 | }, 44 | { 45 | provide: APP_PIPE, 46 | useClass: ValidationPipe, 47 | }, 48 | ], 49 | }) 50 | export class AppModule {} 51 | -------------------------------------------------------------------------------- /src/comment/comment.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommentController } from './comment.controller'; 3 | 4 | describe('Comment Controller', () => { 5 | let controller: CommentController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [CommentController], 10 | }).compile(); 11 | 12 | controller = module.get(CommentController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/comment/comment.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | UseGuards, 6 | Post, 7 | Body, 8 | Delete, 9 | Query, 10 | } from "@nestjs/common"; 11 | 12 | import { CommentService } from "./comment.service"; 13 | import { AuthGuard } from "../shared/auth.guard"; 14 | import { User } from "../user/user.decorator"; 15 | import { CommentDTO } from "./dto/comment.dto"; 16 | 17 | @Controller("comments") 18 | export class CommentController { 19 | constructor(private readonly commentService: CommentService) {} 20 | 21 | @Get("idea/:id") 22 | public showCommentsByIdea( 23 | @Param("id") idea: string, 24 | @Query("page") page: number, 25 | ) { 26 | return this.commentService.showByIdea(idea, page); 27 | } 28 | 29 | @Get("user/:id") 30 | public showCommentsByUser( 31 | @Param("id") user: string, 32 | @Query("page") page: number, 33 | ) { 34 | return this.commentService.showByUser(user, page); 35 | } 36 | 37 | @Post("idea/:id") 38 | @UseGuards(new AuthGuard()) 39 | public createComment( 40 | @Param("id") idea: string, 41 | @User("id") user: string, 42 | @Body() data: CommentDTO, 43 | ) { 44 | return this.commentService.create(idea, user, data); 45 | } 46 | 47 | @Get(":id") 48 | public showComment(@Param("id") id: string) { 49 | return this.commentService.show(id); 50 | } 51 | 52 | @Delete(":id") 53 | @UseGuards(new AuthGuard()) 54 | public destroyComment(@Param("id") id: string, @User("id") user: string) { 55 | return this.commentService.destroy(id, user); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/comment/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | CreateDateColumn, 5 | Column, 6 | ManyToOne, 7 | JoinTable, 8 | } from "typeorm"; 9 | import { ObjectType, Field, ID } from "type-graphql"; 10 | 11 | import { UserEntity } from "../user/user.entity"; 12 | import { IdeaEntity } from "../idea/idea.entity"; 13 | 14 | @ObjectType() 15 | @Entity("comment") 16 | export class CommentEntity { 17 | @Field(() => ID) 18 | @PrimaryGeneratedColumn("uuid") 19 | id: string; 20 | 21 | @Field(() => Date) 22 | @CreateDateColumn() 23 | created: Date; 24 | 25 | @Field() 26 | @Column("text") 27 | comment: string; 28 | 29 | @ManyToOne(() => UserEntity) 30 | @JoinTable() 31 | author: UserEntity; 32 | 33 | @ManyToOne(() => IdeaEntity, idea => idea.comments) 34 | idea: IdeaEntity; 35 | } 36 | -------------------------------------------------------------------------------- /src/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | 4 | import { CommentController } from "./comment.controller"; 5 | import { CommentService } from "./comment.service"; 6 | import { IdeaEntity } from "../idea/idea.entity"; 7 | import { UserEntity } from "../user/user.entity"; 8 | import { CommentEntity } from "./comment.entity"; 9 | import { CommentResolver } from "./comment.resolver"; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([IdeaEntity, UserEntity, CommentEntity])], 13 | controllers: [CommentController], 14 | providers: [CommentService, CommentResolver], 15 | }) 16 | export class CommentModule {} 17 | -------------------------------------------------------------------------------- /src/comment/comment.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommentResolver } from './comment.resolver'; 3 | 4 | describe('CommentResolver', () => { 5 | let resolver: CommentResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CommentResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(CommentResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/comment/comment.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args, Mutation, Context } from "@nestjs/graphql"; 2 | import { UseGuards } from "@nestjs/common"; 3 | 4 | import { CommentEntity } from "./comment.entity"; 5 | import { CommentService } from "./comment.service"; 6 | import { CommentDTO } from "./dto/comment.dto"; 7 | import { AuthGuard } from "../shared/auth.guard"; 8 | 9 | @Resolver() 10 | export class CommentResolver { 11 | constructor(private readonly commentService: CommentService) {} 12 | 13 | @Query(() => CommentEntity) 14 | public comment(@Args("id") id: string) { 15 | return this.commentService.show(id); 16 | } 17 | 18 | @Mutation(() => CommentEntity) 19 | @UseGuards(new AuthGuard()) 20 | public createComment( 21 | @Args("ideaId") ideaId: string, 22 | @Args("comment") comment: CommentDTO, 23 | @Context("user") { id: userId }, 24 | ) { 25 | return this.commentService.create(ideaId, userId, comment); 26 | } 27 | 28 | @Mutation(() => CommentEntity) 29 | @UseGuards(new AuthGuard()) 30 | public deleteComment( 31 | @Args("id") id: string, 32 | @Context("user") { id: userId }, 33 | ) { 34 | return this.commentService.destroy(id, userId); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/comment/comment.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommentService } from './comment.service'; 3 | 4 | describe('CommentService', () => { 5 | let service: CommentService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CommentService], 10 | }).compile(); 11 | 12 | service = module.get(CommentService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/comment/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpException, HttpStatus } from "@nestjs/common"; 2 | import { InjectRepository } from "@nestjs/typeorm"; 3 | import { Repository } from "typeorm"; 4 | 5 | import { CommentEntity } from "./comment.entity"; 6 | import { IdeaEntity } from "../idea/idea.entity"; 7 | import { UserEntity } from "../user/user.entity"; 8 | import { CommentDTO } from "./dto/comment.dto"; 9 | 10 | @Injectable() 11 | export class CommentService { 12 | constructor( 13 | @InjectRepository(CommentEntity) 14 | private readonly commentRepository: Repository, 15 | @InjectRepository(IdeaEntity) 16 | private readonly ideaRepository: Repository, 17 | @InjectRepository(UserEntity) 18 | private readonly userRepository: Repository, 19 | ) {} 20 | 21 | private toResponseObject(comment: CommentEntity) { 22 | const responseObject: any = { 23 | ...comment, 24 | author: comment.author 25 | ? { 26 | id: comment.author.id, 27 | username: comment.author.username, 28 | created: comment.author.created, 29 | updated: comment.author.created, 30 | } 31 | : null, 32 | idea: comment.idea 33 | ? { 34 | id: comment.idea.id, 35 | idea: comment.idea.idea, 36 | description: comment.idea.description, 37 | created: comment.idea.created, 38 | updated: comment.idea.updated, 39 | } 40 | : null, 41 | }; 42 | 43 | return responseObject; 44 | } 45 | 46 | public async showByIdea(ideaId: string, page: number = 1) { 47 | /* const idea = await this.ideaRepository.findOne({ 48 | where: { id }, 49 | relations: ["comments", "comments.author", "comments.idea"], 50 | }); */ 51 | 52 | const comments = await this.commentRepository.find({ 53 | where: { idea: { id: ideaId } }, 54 | relations: ["author"], 55 | take: 25, 56 | skip: 25 * (page - 1), 57 | }); 58 | 59 | return comments.map(idea => this.toResponseObject(idea)); 60 | } 61 | 62 | public async showByUser(userId: string, page: number = 1) { 63 | const comments = await this.commentRepository.find({ 64 | where: { author: { id: userId } }, 65 | relations: ["author"], 66 | take: 25, 67 | skip: 25 * (page - 1), 68 | }); 69 | 70 | return comments.map(comment => this.toResponseObject(comment)); 71 | } 72 | 73 | public async show(id: string) { 74 | const comment = await this.commentRepository.findOne({ 75 | where: { id }, 76 | relations: ["author", "idea"], 77 | }); 78 | 79 | return this.toResponseObject(comment); 80 | } 81 | 82 | public async create(ideaId: string, userId: string, data: CommentDTO) { 83 | const idea = await this.ideaRepository.findOne({ where: { id: ideaId } }); 84 | const user = await this.userRepository.findOne({ where: { id: userId } }); 85 | 86 | const comment = await this.commentRepository.create({ 87 | ...data, 88 | idea, 89 | author: user, 90 | }); 91 | 92 | await this.commentRepository.save(comment); 93 | 94 | return this.toResponseObject(comment); 95 | } 96 | 97 | public async destroy(id: string, userId: string) { 98 | const comment = await this.commentRepository.findOne({ 99 | where: { id }, 100 | relations: ["author", "idea"], 101 | }); 102 | 103 | if (!comment) { 104 | throw new HttpException("Comment not found", HttpStatus.BAD_REQUEST); 105 | } 106 | 107 | if (comment.author.id !== userId) { 108 | throw new HttpException( 109 | "You do not own this comment", 110 | HttpStatus.UNAUTHORIZED, 111 | ); 112 | } 113 | 114 | await this.commentRepository.delete(comment); 115 | 116 | return this.toResponseObject(comment); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/comment/dto/comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "type-graphql"; 2 | import { IsString } from "class-validator"; 3 | 4 | @InputType() 5 | export class CommentDTO { 6 | @Field() 7 | @IsString() 8 | comment: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/idea/dto/idea.dto.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from "type-graphql"; 2 | import { IsString } from "class-validator"; 3 | 4 | @InputType() 5 | export class IdeaDTO { 6 | @Field() 7 | @IsString() 8 | readonly idea: string; 9 | 10 | @Field() 11 | @IsString() 12 | readonly description: string; 13 | } 14 | 15 | @InputType() 16 | export class IdeaUpdateDTO { 17 | @Field({ nullable: true }) 18 | @IsString() 19 | readonly idea?: string; 20 | 21 | @Field({ nullable: true }) 22 | @IsString() 23 | readonly description?: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/idea/idea.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { IdeaController } from './idea.controller'; 3 | 4 | describe('Idea Controller', () => { 5 | let controller: IdeaController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [IdeaController], 10 | }).compile(); 11 | 12 | controller = module.get(IdeaController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/idea/idea.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Put, 6 | Delete, 7 | Body, 8 | Param, 9 | UseGuards, 10 | Query, 11 | } from "@nestjs/common"; 12 | 13 | import { IdeaService } from "./idea.service"; 14 | import { IdeaDTO } from "./dto/idea.dto"; 15 | import { AuthGuard } from "../shared/auth.guard"; 16 | import { User } from "../user/user.decorator"; 17 | 18 | @Controller("idea") 19 | export class IdeaController { 20 | constructor(private readonly ideaService: IdeaService) {} 21 | 22 | @Get() 23 | public showAllIdeas(@Query("page") page: number) { 24 | return this.ideaService.showAll(page); 25 | } 26 | 27 | @Get("/newest") 28 | public showNewestIdeas(@Query("page") page: number) { 29 | return this.ideaService.showAll(page, true); 30 | } 31 | 32 | @Post() 33 | @UseGuards(new AuthGuard()) 34 | public createIdea(@Body() data: IdeaDTO, @User("id") user) { 35 | return this.ideaService.create(data, user); 36 | } 37 | 38 | @Get(":id") 39 | public readIdea(@Param("id") id: string) { 40 | return this.ideaService.read(id); 41 | } 42 | 43 | @Put(":id") 44 | @UseGuards(new AuthGuard()) 45 | public updateIdea( 46 | @Param("id") id: string, 47 | @Body() data: Partial, 48 | @User("id") user, 49 | ) { 50 | return this.ideaService.update(id, data, user); 51 | } 52 | 53 | @Delete(":id") 54 | @UseGuards(new AuthGuard()) 55 | public deleteIdea(@Param("id") id: string, @User("id") user: string) { 56 | return this.ideaService.destroy(id, user); 57 | } 58 | 59 | @Post(":id/upvote") 60 | @UseGuards(new AuthGuard()) 61 | public upvoteIdea(@Param("id") id: string, @User("id") user: string) { 62 | return this.ideaService.upvote(id, user); 63 | } 64 | 65 | @Post(":id/downvote") 66 | @UseGuards(new AuthGuard()) 67 | public downvoteIdea(@Param("id") id: string, @User("id") user: string) { 68 | return this.ideaService.downvote(id, user); 69 | } 70 | 71 | @Post(":id/bookmark") 72 | @UseGuards(new AuthGuard()) 73 | public bookmarkIdea(@Param("id") id: string, @User("id") user: string) { 74 | return this.ideaService.bookmark(id, user); 75 | } 76 | 77 | @Delete(":id/bookmark") 78 | @UseGuards(new AuthGuard()) 79 | public unbookmarkIdea(@Param("id") id: string, @User("id") user: string) { 80 | return this.ideaService.unbookmark(id, user); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/idea/idea.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | Column, 7 | ManyToOne, 8 | ManyToMany, 9 | JoinTable, 10 | OneToMany, 11 | } from "typeorm"; 12 | import { ObjectType, Field, ID, Int } from "type-graphql"; 13 | 14 | import { UserEntity } from "../user/user.entity"; 15 | import { CommentEntity } from "../comment/comment.entity"; 16 | 17 | @ObjectType() 18 | @Entity("idea") 19 | export class IdeaEntity { 20 | @Field(() => ID) 21 | @PrimaryGeneratedColumn("uuid") 22 | id: string; 23 | 24 | @Field(() => Date) 25 | @CreateDateColumn() 26 | created: Date; 27 | 28 | @Field() 29 | @UpdateDateColumn() 30 | updated: Date; 31 | 32 | @Field() 33 | @Column("text") 34 | idea: string; 35 | 36 | @Field() 37 | @Column("text") 38 | description: string; 39 | 40 | @Field(() => UserEntity, { nullable: true }) 41 | @ManyToOne(() => UserEntity, author => author.ideas) 42 | author: UserEntity; 43 | 44 | @ManyToMany(() => UserEntity, { cascade: true }) 45 | @JoinTable() 46 | upvotes: UserEntity[]; 47 | 48 | @Field(() => Int) 49 | upvotesAmount: number; 50 | 51 | @ManyToMany(() => UserEntity, { cascade: true }) 52 | @JoinTable() 53 | downvotes: UserEntity[]; 54 | 55 | @Field(() => Int) 56 | downvotesAmount: number; 57 | 58 | @Field(() => [CommentEntity]) 59 | @OneToMany(() => CommentEntity, comment => comment.idea, { cascade: true }) 60 | comments: CommentEntity[]; 61 | } 62 | -------------------------------------------------------------------------------- /src/idea/idea.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | 4 | import { AppGateway } from "../app.gateway"; 5 | import { IdeaController } from "./idea.controller"; 6 | import { IdeaService } from "./idea.service"; 7 | import { IdeaEntity } from "./idea.entity"; 8 | import { UserEntity } from "../user/user.entity"; 9 | import { IdeaResolver } from "./idea.resolver"; 10 | import { CommentEntity } from "../comment/comment.entity"; 11 | import { CommentService } from "../comment/comment.service"; 12 | 13 | @Module({ 14 | imports: [TypeOrmModule.forFeature([IdeaEntity, UserEntity, CommentEntity])], 15 | controllers: [IdeaController], 16 | providers: [IdeaService, CommentService, IdeaResolver, AppGateway], 17 | }) 18 | export class IdeaModule {} 19 | -------------------------------------------------------------------------------- /src/idea/idea.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { IdeaResolver } from './idea.resolver'; 3 | 4 | describe('IdeaResolver', () => { 5 | let resolver: IdeaResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [IdeaResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(IdeaResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/idea/idea.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | Args, 5 | ResolveProperty, 6 | Parent, 7 | Mutation, 8 | Context, 9 | } from "@nestjs/graphql"; 10 | import { Int } from "type-graphql"; 11 | import { UseGuards } from "@nestjs/common"; 12 | 13 | import { IdeaService } from "./idea.service"; 14 | import { IdeaEntity } from "./idea.entity"; 15 | import { CommentService } from "../comment/comment.service"; 16 | import { IdeaDTO, IdeaUpdateDTO } from "./dto/idea.dto"; 17 | import { AuthGuard } from "../shared/auth.guard"; 18 | import { UserEntity } from "../user/user.entity"; 19 | 20 | @Resolver(() => IdeaEntity) 21 | export class IdeaResolver { 22 | constructor( 23 | private readonly ideaService: IdeaService, 24 | private readonly commentService: CommentService, 25 | ) {} 26 | 27 | @Query(() => [IdeaEntity]) 28 | public ideas( 29 | @Args({ name: "page", type: () => Int, nullable: true }) page?: number, 30 | @Args({ name: "newest", type: () => Boolean, nullable: true }) 31 | newest?: boolean, 32 | ) { 33 | return this.ideaService.showAll(page, newest); 34 | } 35 | 36 | @Query(() => IdeaEntity) 37 | public idea(@Args("id") id: string) { 38 | return this.ideaService.read(id); 39 | } 40 | 41 | @Mutation(() => IdeaEntity) 42 | @UseGuards(new AuthGuard()) 43 | public createIdea( 44 | @Args("input") input: IdeaDTO, 45 | @Context("user") { id: userId }, 46 | ) { 47 | return this.ideaService.create(input, userId); 48 | } 49 | 50 | @Mutation(() => IdeaEntity) 51 | @UseGuards(new AuthGuard()) 52 | public updateIdea( 53 | @Args("id") id: string, 54 | @Args("input") input: IdeaUpdateDTO, 55 | @Context("user") { id: userId }, 56 | ) { 57 | return this.ideaService.update(id, input, userId); 58 | } 59 | 60 | @Mutation(() => IdeaEntity) 61 | @UseGuards(new AuthGuard()) 62 | public deleteIdea(@Args("id") id: string, @Context("user") { id: userId }) { 63 | return this.ideaService.destroy(id, userId); 64 | } 65 | 66 | @Mutation(() => IdeaEntity) 67 | @UseGuards(new AuthGuard()) 68 | public upvote(@Args("id") id: string, @Context("user") { id: userId }) { 69 | return this.ideaService.upvote(id, userId); 70 | } 71 | 72 | @Mutation(() => IdeaEntity) 73 | @UseGuards(new AuthGuard()) 74 | public downvote(@Args("id") id: string, @Context("user") { id: userId }) { 75 | return this.ideaService.downvote(id, userId); 76 | } 77 | 78 | @Mutation(() => UserEntity) 79 | @UseGuards(new AuthGuard()) 80 | public bookmark(@Args("id") id: string, @Context("user") { id: userId }) { 81 | return this.ideaService.bookmark(id, userId); 82 | } 83 | 84 | @Mutation(() => UserEntity) 85 | @UseGuards(new AuthGuard()) 86 | public unbookmark(@Args("id") id: string, @Context("user") { id: userId }) { 87 | return this.ideaService.unbookmark(id, userId); 88 | } 89 | 90 | @ResolveProperty("comments") 91 | public comments(@Parent() { id }) { 92 | return this.commentService.showByIdea(id); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/idea/idea.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { IdeaService } from './idea.service'; 3 | 4 | describe('IdeaService', () => { 5 | let service: IdeaService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [IdeaService], 10 | }).compile(); 11 | 12 | service = module.get(IdeaService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/idea/idea.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpException, HttpStatus } from "@nestjs/common"; 2 | import { InjectRepository } from "@nestjs/typeorm"; 3 | import { Repository } from "typeorm"; 4 | 5 | import { IdeaEntity } from "./idea.entity"; 6 | import { IdeaDTO, IdeaUpdateDTO } from "./dto/idea.dto"; 7 | import { UserEntity } from "../user/user.entity"; 8 | import { Votes } from "../shared/votes.enum"; 9 | import { AppGateway } from "../app.gateway"; 10 | 11 | @Injectable() 12 | export class IdeaService { 13 | constructor( 14 | @InjectRepository(IdeaEntity) 15 | private readonly ideaRepository: Repository, 16 | @InjectRepository(UserEntity) 17 | private readonly userRepository: Repository, 18 | private readonly geteway: AppGateway, 19 | ) {} 20 | 21 | private toResponseObject(idea: IdeaEntity) { 22 | const responseObject: any = { 23 | ...idea, 24 | author: idea.author 25 | ? { 26 | id: idea.author.id, 27 | username: idea.author.username, 28 | created: idea.author.created, 29 | updated: idea.author.created, 30 | } 31 | : null, 32 | }; 33 | 34 | if (responseObject.upvotes) { 35 | responseObject.upvotesAmount = idea.upvotes.length; 36 | } 37 | 38 | if (responseObject.downvotes) { 39 | responseObject.downvotesAmount = idea.downvotes.length; 40 | } 41 | 42 | return responseObject; 43 | } 44 | 45 | private ensureOwnership(idea: IdeaEntity, userId: string) { 46 | if (idea.author.id !== userId) { 47 | throw new HttpException("Incorrect user", HttpStatus.UNAUTHORIZED); 48 | } 49 | } 50 | 51 | private async vote(idea: IdeaEntity, user: UserEntity, vote: Votes) { 52 | const opposite = vote === Votes.UP ? Votes.DOWN : Votes.UP; 53 | 54 | if ( 55 | idea[opposite].some(voter => voter.id === user.id) || 56 | idea[vote].some(voter => voter.id === user.id) 57 | ) { 58 | idea[opposite] = idea[opposite].filter(voter => voter.id !== user.id); 59 | idea[vote] = idea[vote].filter(voter => voter.id !== user.id); 60 | 61 | await this.ideaRepository.save(idea); 62 | } else if (!idea[vote].some(voter => voter.id === user.id)) { 63 | idea[vote].push(user); 64 | 65 | await this.ideaRepository.save(idea); 66 | } else { 67 | throw new HttpException("Unable to cast vote", HttpStatus.BAD_REQUEST); 68 | } 69 | 70 | return idea; 71 | } 72 | 73 | public async showAll(page: number = 1, newest?: boolean) { 74 | const ideas = await this.ideaRepository.find({ 75 | relations: ["author", "upvotes", "downvotes", "comments"], 76 | take: 25, 77 | skip: 25 * (page - 1), 78 | order: newest && { created: "DESC" }, 79 | }); 80 | 81 | return ideas.map(idea => this.toResponseObject(idea)); 82 | } 83 | 84 | public async create(data: IdeaDTO, userId: string) { 85 | const user = await this.userRepository.findOne({ where: { id: userId } }); 86 | const idea = await this.ideaRepository.create({ ...data, author: user }); 87 | 88 | await this.ideaRepository.save(idea); 89 | 90 | this.geteway.wss.emit("newIdea", idea); 91 | 92 | return this.toResponseObject(idea); 93 | } 94 | 95 | public async read(id: string) { 96 | const idea = await this.ideaRepository.findOne({ 97 | where: { id }, 98 | relations: ["author", "upvotes", "downvotes", "comments"], 99 | }); 100 | 101 | if (!idea) { 102 | throw new HttpException("Not found", HttpStatus.NOT_FOUND); 103 | } 104 | 105 | return this.toResponseObject(idea); 106 | } 107 | 108 | public async update( 109 | id: string, 110 | data: Partial, 111 | userId: string, 112 | ) { 113 | let idea = await this.ideaRepository.findOne({ 114 | where: { id }, 115 | relations: ["author"], 116 | }); 117 | 118 | if (!idea) { 119 | throw new HttpException("Not found", HttpStatus.NOT_FOUND); 120 | } 121 | 122 | this.ensureOwnership(idea, userId); 123 | 124 | await this.ideaRepository.update( 125 | { id }, 126 | { idea: data.idea, description: data.description }, 127 | ); 128 | 129 | // return the value updated 130 | idea = await this.ideaRepository.findOne({ 131 | where: { id }, 132 | relations: ["author"], 133 | }); 134 | 135 | return this.toResponseObject(idea); 136 | } 137 | 138 | public async destroy(id: string, userId: string) { 139 | const idea = await this.ideaRepository.findOne({ 140 | where: { id }, 141 | relations: ["author"], 142 | }); 143 | 144 | if (!idea) { 145 | throw new HttpException("Not found", HttpStatus.NOT_FOUND); 146 | } 147 | 148 | this.ensureOwnership(idea, userId); 149 | 150 | await this.ideaRepository.delete({ id }); 151 | 152 | return this.toResponseObject(idea); 153 | } 154 | 155 | public async upvote(id: string, userId: string) { 156 | let idea = await this.ideaRepository.findOne({ 157 | where: { id }, 158 | relations: ["author", "upvotes", "downvotes", "comments"], 159 | }); 160 | const user = await this.userRepository.findOne({ where: { id: userId } }); 161 | 162 | idea = await this.vote(idea, user, Votes.UP); 163 | 164 | return this.toResponseObject(idea); 165 | } 166 | 167 | public async downvote(id: string, userId: string) { 168 | let idea = await this.ideaRepository.findOne({ 169 | where: { id }, 170 | relations: ["author", "upvotes", "downvotes", "comments"], 171 | }); 172 | const user = await this.userRepository.findOne({ where: { id: userId } }); 173 | 174 | idea = await this.vote(idea, user, Votes.DOWN); 175 | 176 | return this.toResponseObject(idea); 177 | } 178 | 179 | public async bookmark(id: string, userId: string) { 180 | const idea = await this.ideaRepository.findOne({ where: { id } }); 181 | const user = await this.userRepository.findOne({ 182 | where: { id: userId }, 183 | relations: ["bookmarks"], 184 | }); 185 | 186 | if (!user.bookmarks.some(bookmark => bookmark.id === idea.id)) { 187 | user.bookmarks.push(idea); 188 | await this.userRepository.save(user); 189 | } else { 190 | throw new HttpException( 191 | "Idea already bookmarked", 192 | HttpStatus.BAD_REQUEST, 193 | ); 194 | } 195 | 196 | return user; 197 | } 198 | 199 | public async unbookmark(id: string, userId: string) { 200 | const idea = await this.ideaRepository.findOne({ where: { id } }); 201 | const user = await this.userRepository.findOne({ 202 | where: { id: userId }, 203 | relations: ["bookmarks"], 204 | }); 205 | 206 | if (user.bookmarks.some(bookmark => bookmark.id === idea.id)) { 207 | user.bookmarks = user.bookmarks.filter( 208 | bookmark => bookmark.id !== idea.id, 209 | ); 210 | 211 | await this.userRepository.save(user); 212 | } else { 213 | throw new HttpException( 214 | "Idea already bookmarked", 215 | HttpStatus.BAD_REQUEST, 216 | ); 217 | } 218 | 219 | return user; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { NestFactory } from "@nestjs/core"; 3 | 4 | import { AppModule } from "./app.module"; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | await app.listen(process.env.PORT || 3000); 9 | } 10 | 11 | bootstrap(); 12 | -------------------------------------------------------------------------------- /src/shared/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | HttpStatus, 6 | HttpException, 7 | } from "@nestjs/common"; 8 | import * as jwt from "jsonwebtoken"; 9 | import { GqlExecutionContext } from "@nestjs/graphql"; 10 | 11 | @Injectable() 12 | export class AuthGuard implements CanActivate { 13 | async canActivate(context: ExecutionContext): Promise { 14 | const request = context.switchToHttp().getRequest(); 15 | 16 | if (request) { 17 | if (!request.headers.authorization) { 18 | return false; 19 | } 20 | 21 | const decoded = await this.validateToken(request.headers.authorization); 22 | request.user = decoded; 23 | 24 | return true; 25 | } else { 26 | const ctx = GqlExecutionContext.create(context).getContext(); 27 | 28 | if (!ctx.headers.authorization) { 29 | return false; 30 | } 31 | 32 | ctx.user = await this.validateToken(ctx.headers.authorization); 33 | 34 | return true; 35 | } 36 | } 37 | 38 | private async validateToken(auth: string) { 39 | const token = auth.split(" "); 40 | 41 | if (token[0] !== "Bearer") { 42 | throw new HttpException("Invalid token", HttpStatus.FORBIDDEN); 43 | } 44 | 45 | try { 46 | const decode = await jwt.verify(token[1], process.env.JWT_SECRET); 47 | 48 | return decode; 49 | } catch (err) { 50 | const message = "Token error: " + (err.message || err.name); 51 | throw new HttpException(message, HttpStatus.FORBIDDEN); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/shared/http-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Catch, 3 | ExceptionFilter, 4 | HttpException, 5 | ArgumentsHost, 6 | Logger, 7 | HttpStatus, 8 | } from "@nestjs/common"; 9 | import { Request, Response } from "express"; 10 | import { GqlArgumentsHost, GqlExceptionFilter } from "@nestjs/graphql"; 11 | import { GraphQLResolveInfo } from "graphql"; 12 | 13 | @Catch() 14 | export class HttpErrorFilter implements ExceptionFilter, GqlExceptionFilter { 15 | catch(exception: HttpException, host: ArgumentsHost) { 16 | const ctx = host.switchToHttp(); 17 | const response = ctx.getResponse(); 18 | const request = ctx.getRequest(); 19 | 20 | const gqlHost = GqlArgumentsHost.create(host); 21 | const info = gqlHost.getInfo(); 22 | 23 | const status = exception.getStatus 24 | ? exception.getStatus() 25 | : HttpStatus.INTERNAL_SERVER_ERROR; 26 | 27 | if (status === HttpStatus.INTERNAL_SERVER_ERROR) { 28 | // tslint:disable-next-line: no-console 29 | console.error(exception); 30 | } 31 | 32 | const errorResponse = { 33 | statusCode: status, 34 | timestamp: new Date().toLocaleDateString(), 35 | error: 36 | status !== HttpStatus.INTERNAL_SERVER_ERROR 37 | ? exception.message.error || exception.message || null 38 | : "Internal server error", 39 | }; 40 | 41 | // This is for REST petitions 42 | if (request) { 43 | const error = { 44 | ...errorResponse, 45 | path: request.url, 46 | method: request.method, 47 | }; 48 | 49 | Logger.error( 50 | `${request.method} ${request.url}`, 51 | JSON.stringify(error), 52 | "ExceptionFilter", 53 | ); 54 | 55 | response.status(status).json(errorResponse); 56 | } else { 57 | // This is for GRAPHQL petitions 58 | const error = { 59 | ...errorResponse, 60 | type: info.parentType, 61 | field: info.fieldName, 62 | }; 63 | 64 | Logger.error( 65 | `${info.parentType} ${info.fieldName}`, 66 | JSON.stringify(error), 67 | "ExceptionFilter", 68 | ); 69 | 70 | return exception; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/shared/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | Logger, 7 | } from "@nestjs/common"; 8 | import { Request } from "express"; 9 | import { Observable } from "rxjs"; 10 | import { tap } from "rxjs/operators"; 11 | import { GqlExecutionContext } from "@nestjs/graphql"; 12 | 13 | @Injectable() 14 | export class LoggingInterceptor implements NestInterceptor { 15 | intercept(context: ExecutionContext, next: CallHandler): Observable { 16 | const request = context.switchToHttp().getRequest(); 17 | const now = Date.now(); 18 | 19 | // This is for REST petitions 20 | if (request) { 21 | return next 22 | .handle() 23 | .pipe( 24 | tap(() => 25 | Logger.log( 26 | `${request.method} ${request.url} ${Date.now() - now}ms`, 27 | `${context.getClass().name}.${context.getHandler().name}`, 28 | ), 29 | ), 30 | ); 31 | } else { 32 | // This is for GRAPHQL petitions 33 | const ctx: any = GqlExecutionContext.create(context); 34 | const resolverName = ctx.constructorRef.name; 35 | const info = ctx.getInfo(); 36 | 37 | return next 38 | .handle() 39 | .pipe( 40 | tap(() => 41 | Logger.log( 42 | `${info.parentType} "${info.fieldName}" ${Date.now() - now}ms`, 43 | resolverName, 44 | ), 45 | ), 46 | ); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/shared/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipeTransform, 3 | Injectable, 4 | ArgumentMetadata, 5 | HttpException, 6 | HttpStatus, 7 | } from "@nestjs/common"; 8 | import { validate, ValidationError } from "class-validator"; 9 | import { plainToClass } from "class-transformer"; 10 | 11 | @Injectable() 12 | export class ValidationPipe implements PipeTransform { 13 | async transform(value: any, { metatype }: ArgumentMetadata) { 14 | if (value instanceof Object && this.isEmpty(value)) { 15 | throw new HttpException( 16 | "Validation failed: No body submitted", 17 | HttpStatus.BAD_REQUEST, 18 | ); 19 | } 20 | 21 | if (!metatype || !this.toValidate(metatype)) { 22 | return value; 23 | } 24 | 25 | const object = plainToClass(metatype, value); 26 | const errors = await validate(object); 27 | 28 | if (errors.length > 0) { 29 | throw new HttpException( 30 | this.formatErrors(errors), 31 | HttpStatus.BAD_REQUEST, 32 | ); 33 | } 34 | 35 | return value; 36 | } 37 | 38 | private toValidate(metatype: Function): boolean { 39 | const types: Function[] = [String, Boolean, Number, Array, Object]; 40 | 41 | return !types.includes(metatype); 42 | } 43 | 44 | private formatErrors(errors: ValidationError[]) { 45 | return errors.map(err => { 46 | for (const property in err.constraints) { 47 | if (err.constraints.hasOwnProperty(property)) { 48 | return { 49 | path: err.property, 50 | message: err.constraints[property], 51 | }; 52 | } 53 | } 54 | }); 55 | } 56 | 57 | private isEmpty(value: any) { 58 | if (Object.keys(value).length > 0) { 59 | return false; 60 | } 61 | 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/shared/votes.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Votes { 2 | UP = "upvotes", 3 | DOWN = "downvotes", 4 | } 5 | -------------------------------------------------------------------------------- /src/user/Auth.response.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class Auth { 5 | @Field() 6 | username: string; 7 | 8 | @Field({ nullable: true }) 9 | token?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from "type-graphql"; 2 | import { IsNotEmpty } from "class-validator"; 3 | 4 | @InputType() 5 | export class UserDTO { 6 | @Field() 7 | @IsNotEmpty() 8 | username: string; 9 | 10 | @Field() 11 | @IsNotEmpty() 12 | password: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | 4 | describe('User Controller', () => { 5 | let controller: UserController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | }).compile(); 11 | 12 | controller = module.get(UserController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Get, Body, UseGuards, Query } from "@nestjs/common"; 2 | 3 | import { UserService } from "./user.service"; 4 | import { UserDTO } from "./dto/user.dto"; 5 | import { AuthGuard } from "../shared/auth.guard"; 6 | import { User } from "./user.decorator"; 7 | 8 | @Controller() 9 | export class UserController { 10 | constructor(private readonly userService: UserService) {} 11 | 12 | @Get("users") 13 | @UseGuards(new AuthGuard()) 14 | showAllusers(@User("id") user, @Query("page") page: number) { 15 | // tslint:disable-next-line: no-console 16 | console.log(user); 17 | return this.userService.showAll(page); 18 | } 19 | 20 | @Post("login") 21 | login(@Body() data: UserDTO) { 22 | return this.userService.login(data); 23 | } 24 | 25 | @Post("register") 26 | register(@Body() data: UserDTO) { 27 | return this.userService.register(data); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/user/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator } from "@nestjs/common"; 2 | 3 | export const User = createParamDecorator((data, request) => { 4 | return data ? request.user[data] : request.user; 5 | }); 6 | -------------------------------------------------------------------------------- /src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | CreateDateColumn, 5 | Column, 6 | BeforeInsert, 7 | OneToMany, 8 | UpdateDateColumn, 9 | ManyToMany, 10 | JoinTable, 11 | } from "typeorm"; 12 | import { ObjectType, Field, ID } from "type-graphql"; 13 | import * as argon2 from "argon2"; 14 | 15 | import { IdeaEntity } from "../idea/idea.entity"; 16 | import { CommentEntity } from "../comment/comment.entity"; 17 | 18 | @ObjectType() 19 | @Entity("user") 20 | export class UserEntity { 21 | @Field(() => ID) 22 | @PrimaryGeneratedColumn() 23 | id: string; 24 | 25 | @Field(() => Date) 26 | @CreateDateColumn() 27 | created: Date; 28 | 29 | @Field(() => Date) 30 | @UpdateDateColumn() 31 | updated: Date; 32 | 33 | @Field() 34 | @Column({ 35 | type: "text", 36 | unique: true, 37 | }) 38 | username: string; 39 | 40 | @Column("text") 41 | password: string; 42 | 43 | @Field(() => [IdeaEntity]) 44 | @OneToMany(() => IdeaEntity, idea => idea.author) 45 | ideas: IdeaEntity[]; 46 | 47 | @Field(() => [IdeaEntity]) 48 | @ManyToMany(() => IdeaEntity, { cascade: true }) 49 | @JoinTable() 50 | bookmarks: IdeaEntity[]; 51 | 52 | @Field(() => [CommentEntity]) 53 | comments: CommentEntity[]; 54 | 55 | @BeforeInsert() 56 | async hashPassword() { 57 | this.password = await argon2.hash(this.password); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | 4 | import { UserController } from "./user.controller"; 5 | import { UserService } from "./user.service"; 6 | import { UserEntity } from "./user.entity"; 7 | import { UserResolver } from "./user.resolver"; 8 | import { CommentEntity } from "../comment/comment.entity"; 9 | import { CommentService } from "../comment/comment.service"; 10 | import { IdeaEntity } from "../idea/idea.entity"; 11 | 12 | @Module({ 13 | imports: [TypeOrmModule.forFeature([UserEntity, IdeaEntity, CommentEntity])], 14 | controllers: [UserController], 15 | providers: [UserService, CommentService, UserResolver], 16 | }) 17 | export class UserModule {} 18 | -------------------------------------------------------------------------------- /src/user/user.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserResolver } from './user.resolver'; 3 | 4 | describe('UserResolver', () => { 5 | let resolver: UserResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(UserResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | Args, 5 | ResolveProperty, 6 | Parent, 7 | Mutation, 8 | Context, 9 | } from "@nestjs/graphql"; 10 | import { UseGuards } from "@nestjs/common"; 11 | import { Int } from "type-graphql"; 12 | 13 | import { UserEntity } from "./user.entity"; 14 | import { UserService } from "./user.service"; 15 | import { CommentService } from "../comment/comment.service"; 16 | import { UserDTO } from "./dto/user.dto"; 17 | import { Auth } from "./Auth.response"; 18 | import { AuthGuard } from "../shared/auth.guard"; 19 | 20 | @Resolver(() => UserEntity) 21 | export class UserResolver { 22 | constructor( 23 | private readonly userService: UserService, 24 | private readonly commentService: CommentService, 25 | ) {} 26 | 27 | @Query(() => [UserEntity]) 28 | public async users( 29 | @Args({ name: "page", type: () => Int, nullable: true }) page?: number, 30 | ) { 31 | const users = await this.userService.showAll(page); 32 | 33 | return users; 34 | } 35 | 36 | @Query(() => UserEntity) 37 | public user(@Args("username") username: string) { 38 | return this.userService.read(username); 39 | } 40 | 41 | @Query(() => String) 42 | @UseGuards(new AuthGuard()) 43 | public whoami(@Context("user") { username }) { 44 | return username; 45 | } 46 | 47 | @Mutation(() => Auth) 48 | public async login(@Args("input") input: UserDTO) { 49 | const response = await this.userService.login(input); 50 | 51 | return { 52 | token: response.token, 53 | username: response.user.username, 54 | }; 55 | } 56 | 57 | @Mutation(() => Auth) 58 | public async register(@Args("input") input: UserDTO) { 59 | const response = await this.userService.register(input); 60 | 61 | return { 62 | username: response.username, 63 | }; 64 | } 65 | 66 | @ResolveProperty("comments") 67 | public comments(@Parent() { id }) { 68 | return this.commentService.showByUser(id); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpException, HttpStatus } from "@nestjs/common"; 2 | import { InjectRepository } from "@nestjs/typeorm"; 3 | import { Repository } from "typeorm"; 4 | import * as argon2 from "argon2"; 5 | import * as jwt from "jsonwebtoken"; 6 | 7 | import { UserEntity } from "./user.entity"; 8 | import { UserDTO } from "./dto/user.dto"; 9 | 10 | @Injectable() 11 | export class UserService { 12 | constructor( 13 | @InjectRepository(UserEntity) 14 | private readonly userRepository: Repository, 15 | ) {} 16 | 17 | async showAll(page: number = 1) { 18 | const users = await this.userRepository.find({ 19 | select: ["id", "username", "created"], 20 | relations: ["ideas", "bookmarks"], 21 | take: 25, 22 | skip: 25 * (page - 1), 23 | }); 24 | 25 | return users; 26 | } 27 | 28 | async read(username: string) { 29 | const user = await this.userRepository.findOne({ 30 | where: { username }, 31 | relations: ["ideas", "bookmarks"], 32 | }); 33 | 34 | return user; 35 | } 36 | 37 | async login({ username, password }: UserDTO) { 38 | const user = await this.userRepository.findOne({ where: { username } }); 39 | 40 | if (!user) { 41 | throw new HttpException( 42 | { path: "user", message: "the user is wrong" }, 43 | HttpStatus.BAD_REQUEST, 44 | ); 45 | } 46 | 47 | const isPasswordCorrect = await argon2.verify(user.password, password); 48 | 49 | if (!isPasswordCorrect) { 50 | throw new HttpException( 51 | { path: "password", message: "the password is wrong" }, 52 | HttpStatus.BAD_REQUEST, 53 | ); 54 | } 55 | 56 | const token = jwt.sign( 57 | { id: user.id, username: user.username }, 58 | process.env.JWT_SECRET, 59 | { expiresIn: "7d" }, 60 | ); 61 | 62 | return { 63 | token, 64 | user: { 65 | id: user.id, 66 | username: user.username, 67 | created: user.created, 68 | }, 69 | }; 70 | } 71 | 72 | async register(data: UserDTO) { 73 | let user = await this.userRepository.findOne({ 74 | where: { username: data.username }, 75 | }); 76 | 77 | if (user) { 78 | throw new HttpException( 79 | "the user already exists", 80 | HttpStatus.BAD_REQUEST, 81 | ); 82 | } 83 | 84 | user = await this.userRepository.create(data); 85 | await this.userRepository.save(user); 86 | 87 | return user; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "member-access": [false], 9 | "ordered-imports": [false], 10 | "max-line-length": [true, 150], 11 | "member-ordering": [false], 12 | "interface-name": [false], 13 | "arrow-parens": false, 14 | "object-literal-sort-keys": false, 15 | "ban-types": false, 16 | "max-classes-per-file": false 17 | }, 18 | "rulesDirectory": [] 19 | } 20 | --------------------------------------------------------------------------------