├── .editorconfig ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config └── config.json ├── databaseSetup.sql ├── docker-compose.yml ├── example.env ├── jest.config.js ├── migrations ├── 20190913203833-users.js ├── 20190919130428-posts.js ├── 20190919135209-users_fix.js ├── 20190919144323-posts_fix.js ├── 20190919192644-posts_fix.js └── 20190920201356-comment.js ├── package.json ├── serverInit.sh ├── src ├── __test__ │ ├── comments │ │ ├── commentModel.spec.ts │ │ ├── comments.spec.ts │ │ └── createComment.spec.ts │ ├── post │ │ ├── addPost.spec.ts │ │ ├── editPost.spec.ts │ │ ├── post.spec.ts │ │ ├── postModel.spec.ts │ │ └── posts.spec.ts │ ├── setup │ │ ├── setup.ts │ │ └── setupJest.js │ ├── shared │ │ └── node.spec.ts │ ├── user │ │ ├── changePassword.spec.ts │ │ ├── login.spec.ts │ │ ├── me.spec.ts │ │ ├── register.spec.ts │ │ ├── user.spec.ts │ │ ├── userModel.spec.ts │ │ └── users.spec.ts │ └── util │ │ └── testClient.ts ├── config │ ├── cors.ts │ └── sequelize.ts ├── index.ts ├── models │ ├── Comment.model.ts │ ├── Post.model.ts │ └── User.model.ts ├── modules │ ├── comment │ │ ├── resolvers.ts │ │ ├── resolvers │ │ │ ├── commentAuthor.ts │ │ │ ├── commentPost.ts │ │ │ ├── comments.ts │ │ │ └── createComment.ts │ │ ├── schema.graphql │ │ └── types │ │ │ └── typeMap.ts │ ├── courseUtil │ │ ├── resolvers.ts │ │ └── schema.graphql │ ├── index.ts │ ├── mergeSchemas.ts │ ├── middleware │ │ ├── auth.ts │ │ └── errorMessages.ts │ ├── post │ │ ├── resolvers.ts │ │ ├── resolvers │ │ │ ├── editPost.ts │ │ │ ├── feed.ts │ │ │ ├── post.ts │ │ │ ├── user.ts │ │ │ └── userCreatePost.ts │ │ ├── schema.graphql │ │ └── types │ │ │ ├── errorMessages.ts │ │ │ └── typeMap.ts │ ├── schema.graphql │ ├── shared │ │ ├── resolvers.ts │ │ └── types │ │ │ └── typeMaps.ts │ └── user │ │ ├── resolvers.ts │ │ ├── resolvers │ │ ├── changePassword.ts │ │ ├── login.ts │ │ ├── queries.ts │ │ ├── register.ts │ │ ├── userAdded.ts │ │ └── users.ts │ │ └── types │ │ ├── errorMessages.ts │ │ └── typeMap.ts ├── scripts │ ├── genSchemaFile.ts │ ├── genSchemaTypes.ts │ └── populateDB.ts ├── server.ts ├── services │ ├── pubsub.ts │ └── sequelize.ts ├── types │ ├── graphql-utils.d.ts │ ├── merge-graphql-schemas.d.ts │ ├── request.d.ts │ └── schemaTypes.d.ts └── util │ ├── applyMiddleware.ts │ ├── connectionUtils.ts │ ├── constants.ts │ ├── graphqlId.ts │ ├── typeMap.ts │ └── verifyJwt.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env* 59 | 60 | # next.js build output 61 | .next 62 | 63 | .vscode 64 | 65 | #generated file 66 | mainSchema.graphql 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /srv/app 3 | COPY . . 4 | RUN yarn install 5 | RUN npm install -g ts-node 6 | ENV NODE_ENV development 7 | CMD [ "npx", "ts-node", "--files", "src/index.ts" ] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Luiz Victor Linhares Rocha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # relay-modern-typescript-server 2 | 3 | A graphql server written for [sibelius' relay modern course](https://relay-modern-course.now.sh/packages/) 4 | 5 | This graphql server supports: 6 | 7 | - registration 8 | - authentication 9 | - password change 10 | - user query by id 11 | - paginated user query with connection, ordered by name, ascending 12 | - post creation by user 13 | - post query by id 14 | - paginated post query with connection, ordered by creation date, descening 15 | 16 | ## How to use this server 17 | 18 | This project uses any SQL database supported by sequelize to run, the easiest way do it is with [docker-compose](https://docs.docker.com/compose/install/) by running bellow commands 19 | 20 | ```bash 21 | git clone https://github.com/BigsonLvrocha/relay-modern-typescript-server.git 22 | cd relay-modern-typescript-server 23 | docker-compose up 24 | ``` 25 | 26 | after the process is finished, just [open the browser on localhos:5555](http://localhost:5555) and you'll see yoga's graphql client 27 | 28 | If you would like to, you can setup with your own configuration by setting up your own .env file 29 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "username": "postgres", 4 | "password": null, 5 | "database": "relay_modern_development", 6 | "host": "db", 7 | "dialect": "postgres" 8 | }, 9 | "test": { 10 | "username": "postgres", 11 | "password": null, 12 | "database": "relay_modern_test", 13 | "host": "0.0.0.0", 14 | "dialect": "postgres" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /databaseSetup.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE relay_modern_development 2 | CREATE DATABASE relay_modern_test 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | db: 4 | image: postgres:alpine 5 | ports: 6 | - 127.0.0.1:5432:5432 7 | web: 8 | build: . 9 | ports: 10 | - 127.0.0.1:5555:5555 11 | depends_on: 12 | - db 13 | volumes: 14 | - "./serverInit.sh:/usr/local/bin/serverInit.sh" 15 | command: serverInit.sh 16 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | DB_USERNAME=postgres 2 | DB_DATABASE=relay_modern_development 3 | DB_HOSTNAME=localhost 4 | APP_SECRET=asdf 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: "./src/__test__/setup/setup.ts", 3 | setupFilesAfterEnv: ["./src/__test__/setup/setupJest.js"], 4 | testEnvironment: "node", 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | testRegex: "(\\.|/)(test|spec)\\.(jsx?|tsx?)$", 9 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 10 | }; 11 | -------------------------------------------------------------------------------- /migrations/20190913203833-users.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.createTable("users", { 6 | _id: { 7 | type: Sequelize.UUID, 8 | primaryKey: true 9 | }, 10 | password: Sequelize.STRING, 11 | name: { 12 | type: Sequelize.STRING, 13 | unique: true 14 | }, 15 | email: Sequelize.STRING, 16 | active: Sequelize.BOOLEAN 17 | }); 18 | }, 19 | 20 | down: (queryInterface, Sequelize) => { 21 | return queryInterface.dropTable("users"); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /migrations/20190919130428-posts.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.createTable("posts", { 6 | _id: { 7 | type: Sequelize.UUID, 8 | primaryKey: true 9 | }, 10 | createdAt: { 11 | type: Sequelize.DATE, 12 | allowNul: false 13 | }, 14 | updatedAt: { 15 | type: Sequelize.DATE, 16 | allowNul: false 17 | }, 18 | title: { 19 | type: Sequelize.STRING, 20 | allowNul: false 21 | }, 22 | description: Sequelize.STRING, 23 | authorId: { 24 | type: Sequelize.UUID, 25 | references: { 26 | model: "users", 27 | key: "_id" 28 | }, 29 | allowNull: false, 30 | onDelete: "CASCADE", 31 | onUpdate: "CASCADE" 32 | } 33 | }); 34 | }, 35 | 36 | down: (queryInterface, Sequelize) => { 37 | return queryInterface.dropTable("posts"); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /migrations/20190919135209-users_fix.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface 6 | .bulkDelete( 7 | "users", 8 | {}, 9 | { 10 | truncate: true, 11 | cascade: true 12 | } 13 | ) 14 | .then(() => { 15 | const promise1 = queryInterface.changeColumn("users", "name", { 16 | type: Sequelize.STRING, 17 | allowNull: false, 18 | unique: true 19 | }); 20 | const promise2 = queryInterface.changeColumn("users", "active", { 21 | type: Sequelize.BOOLEAN, 22 | allowNull: false, 23 | defaultValue: false 24 | }); 25 | const promise3 = queryInterface.changeColumn("users", "email", { 26 | type: Sequelize.STRING, 27 | allowNull: false, 28 | unique: true 29 | }); 30 | return Promise.all([promise1, promise2, promise3]); 31 | }); 32 | }, 33 | 34 | down: (queryInterface, Sequelize) => { 35 | const promise1 = queryInterface.changeColumn("users", "name", { 36 | type: Sequelize.STRING, 37 | unique: true 38 | }); 39 | 40 | const promise2 = queryInterface.changeColumn("users", "active", { 41 | type: Sequelize.BOOLEAN 42 | }); 43 | const promise3 = queryInterface.changeColumn("users", "email", { 44 | type: Sequelize.BOOLEAN, 45 | type: Sequelize.STRING, 46 | allowNull: false 47 | }); 48 | return Promise.all([promise1, promise2, promise3]); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /migrations/20190919144323-posts_fix.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.changeColumn("posts", "description", Sequelize.TEXT); 6 | }, 7 | 8 | down: (queryInterface, Sequelize) => { 9 | return queryInterface.changeColumn("posts", "description", Sequelize.TEXT); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20190919192644-posts_fix.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.addConstraint("posts", ["createdAt", "authorId"], { 6 | type: "unique", 7 | name: "unique_posts_per_user" 8 | }); 9 | }, 10 | 11 | down: (queryInterface, Sequelize) => { 12 | return queryInterface.removeConstraint("posts", "unique_posts_per_user"); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /migrations/20190920201356-comment.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface 6 | .createTable("comments", { 7 | _id: { 8 | type: Sequelize.UUID, 9 | primaryKey: true 10 | }, 11 | authorId: { 12 | type: Sequelize.UUID, 13 | references: { 14 | model: "users", 15 | key: "_id" 16 | }, 17 | allowNull: false, 18 | onDelete: "CASCADE", 19 | onUpdate: "CASCADE" 20 | }, 21 | postId: { 22 | type: Sequelize.UUID, 23 | references: { 24 | model: "posts", 25 | key: "_id" 26 | }, 27 | allowNull: false, 28 | onDelete: "CASCADE", 29 | onUpdate: "CASCADE" 30 | }, 31 | comment: { 32 | type: Sequelize.TEXT, 33 | allowNull: false 34 | }, 35 | createdAt: { 36 | type: Sequelize.DATE, 37 | allowNull: false 38 | }, 39 | updatedAt: { 40 | type: Sequelize.DATE, 41 | allowNull: false 42 | } 43 | }) 44 | .then(() => { 45 | return queryInterface.addConstraint( 46 | "comments", 47 | ["authorId", "postId", "createdAt"], 48 | { 49 | type: "unique" 50 | } 51 | ); 52 | }); 53 | }, 54 | 55 | down: (queryInterface, Sequelize) => { 56 | return queryInterface.dropTable("comments"); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "relay-modern-typescript-server", 3 | "version": "1.0.0", 4 | "description": "Server for the sibelius' modern relay course", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:BigsonLvrocha/relay-modern-typescript-server.git", 7 | "author": "Luiz Victor Linhares Rocha ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "bcryptjs": "^2.4.3", 11 | "express": "^4.17.1", 12 | "glob": "^7.1.4", 13 | "graphql": "14.5.5", 14 | "graphql-tools": "^4.0.5", 15 | "graphql-yoga": "^1.18.3", 16 | "jsonwebtoken": "^8.5.1", 17 | "merge-graphql-schemas": "^1.7.0", 18 | "pg": "^7.12.1", 19 | "reflect-metadata": "^0.1.13", 20 | "sequelize": "^5.18.4", 21 | "sequelize-typescript": "^1.0.0", 22 | "typed-promisify": "^0.4.0", 23 | "uuid": "^3.3.3" 24 | }, 25 | "devDependencies": { 26 | "@gql2ts/from-schema": "^1.10.1", 27 | "@types/bcryptjs": "^2.4.2", 28 | "@types/bluebird": "^3.5.27", 29 | "@types/express": "^4.17.1", 30 | "@types/faker": "^4.1.5", 31 | "@types/jest": "^24.0.18", 32 | "@types/jsonwebtoken": "^8.3.3", 33 | "@types/lodash": "^4.14.138", 34 | "@types/node": "^12.7.5", 35 | "@types/request-promise": "^4.1.44", 36 | "@types/sequelize": "^4.28.4", 37 | "@types/uuid": "^3.4.5", 38 | "@types/validator": "^10.11.3", 39 | "dotenv": "^8.1.0", 40 | "faker": "^4.1.0", 41 | "gql2ts": "^1.10.1", 42 | "jest": "^24.9.0", 43 | "lodash": "^4.17.15", 44 | "nodemon": "^1.19.2", 45 | "request": "^2.88.0", 46 | "request-promise": "^4.2.4", 47 | "sequelize-cli": "^5.5.1", 48 | "ts-jest": "^24.1.0", 49 | "ts-node": "8.3.0", 50 | "tslint": "^5.20.0", 51 | "tslint-config-prettier": "^1.18.0", 52 | "typescript": "^3.6.3" 53 | }, 54 | "scripts": { 55 | "gen-schema-types": "ts-node --files src/scripts/genSchemaTypes.ts", 56 | "start": "nodemon --files src/index.ts", 57 | "test": "NODE_ENV=test NODE_TS_FILES=true jest --forceExit --runInBand", 58 | "build": "echo builded", 59 | "gen-schema-file": "ts-node --files src/scripts/genSchemaFile.ts", 60 | "populate-db": "ts-node --files src/scripts/populateDB.ts" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /serverInit.sh: -------------------------------------------------------------------------------- 1 | npx sequelize db:create 2 | npx sequelize db:migrate 3 | yarn populate-db 4 | ts-node --files src/index.ts 5 | -------------------------------------------------------------------------------- /src/__test__/comments/commentModel.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import * as faker from "faker"; 3 | import { User } from "../../models/User.model"; 4 | import { ModelCtor } from "sequelize/types"; 5 | import { Post } from "../../models/Post.model"; 6 | import { Comment } from "../../models/Comment.model"; 7 | 8 | const UserModel = sequelize.models.User as ModelCtor; 9 | const PostModel = sequelize.models.Post as ModelCtor; 10 | const CommentModel = sequelize.models.Comment as ModelCtor; 11 | 12 | describe("Post model plugin", () => { 13 | it("creates post for user", async () => { 14 | const email = faker.internet.email(); 15 | const password = faker.internet.password(); 16 | const name = faker.internet.userName(); 17 | const title = faker.name.title(); 18 | const description = faker.lorem.paragraph(); 19 | const user = (await UserModel.create({ 20 | email, 21 | password, 22 | name, 23 | active: true 24 | })) as User; 25 | const post = (await PostModel.create({ 26 | title, 27 | description, 28 | authorId: user._id 29 | })) as Post; 30 | const comment = (await CommentModel.create({ 31 | comment: faker.lorem.paragraph(), 32 | postId: post._id, 33 | authorId: user._id 34 | })) as Post; 35 | const post2 = (await PostModel.findByPk(post._id, { 36 | include: [Comment] 37 | })) as Post; 38 | expect(post2._id).toEqual(post2._id); 39 | expect(post2.comments).toHaveLength(1); 40 | expect(post2.comments[0]._id).toEqual(comment._id); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/__test__/comments/comments.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import * as faker from "faker"; 3 | import { User } from "../../models/User.model"; 4 | import { ModelCtor } from "sequelize/types"; 5 | import { Post } from "../../models/Post.model"; 6 | import { Comment } from "../../models/Comment.model"; 7 | import { range } from "lodash"; 8 | import { TestClient } from "../util/testClient"; 9 | import { comment2Cursor } from "../../modules/comment/types/typeMap"; 10 | 11 | const UserModel = sequelize.models.User as ModelCtor; 12 | const PostModel = sequelize.models.Post as ModelCtor; 13 | const CommentModel = sequelize.models.Comment as ModelCtor; 14 | let postId: string; 15 | 16 | describe("Post model plugin", () => { 17 | beforeAll(async () => { 18 | const usersRange = range(0, 20); 19 | const usersPromise = usersRange.map(() => 20 | UserModel.create({ 21 | email: faker.internet.email(), 22 | name: faker.internet.userName(), 23 | password: faker.internet.password() 24 | }) 25 | ) as Array>; 26 | const users = await Promise.all(usersPromise); 27 | const post = (await PostModel.create({ 28 | title: faker.name.title(), 29 | description: faker.lorem.paragraph(), 30 | authorId: users[0]._id 31 | })) as Post; 32 | postId = post._id; 33 | const promises = users.map(user => 34 | CommentModel.create({ 35 | authorId: user._id, 36 | postId, 37 | comment: faker.lorem.paragraph() 38 | }) 39 | ); 40 | await Promise.all(promises); 41 | }); 42 | 43 | it("lists first 10 on default", async () => { 44 | const client = new TestClient(process.env.TEST_HOST as string); 45 | const comments = (await CommentModel.findAll({ 46 | limit: 10, 47 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 48 | where: { postId } 49 | })) as Comment[]; 50 | const response = await client.postWithComments(`post-${postId}`); 51 | expect(response.errors).toBeUndefined(); 52 | expect(response.data.post).not.toBeUndefined(); 53 | expect(response.data.post).not.toBeNull(); 54 | expect(response.data.post.comments.edges).toHaveLength(10); 55 | expect(response.data.post.comments.edges[0].node._id).toEqual( 56 | comments[0]._id 57 | ); 58 | expect(response.data.post.comments.edges[9].node._id).toEqual( 59 | comments[9]._id 60 | ); 61 | }); 62 | 63 | it("list first 5", async () => { 64 | const client = new TestClient(process.env.TEST_HOST as string); 65 | const comments = (await CommentModel.findAll({ 66 | limit: 5, 67 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 68 | where: { postId } 69 | })) as Comment[]; 70 | const response = await client.postWithComments( 71 | `post-${postId}`, 72 | undefined, 73 | 5 74 | ); 75 | expect(response.errors).toBeUndefined(); 76 | expect(response.data.post).not.toBeNull(); 77 | expect(response.data.post.comments.edges).toHaveLength(5); 78 | expect(response.data.post.comments.edges[0].node._id).toEqual( 79 | comments[0]._id 80 | ); 81 | expect(response.data.post.comments.edges[4].node._id).toEqual( 82 | comments[4]._id 83 | ); 84 | }); 85 | 86 | it("list last 10", async () => { 87 | const client = new TestClient(process.env.TEST_HOST as string); 88 | const totalCount = await CommentModel.count({ where: { postId } }); 89 | const comments = (await CommentModel.findAll({ 90 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 91 | where: { postId }, 92 | limit: 10, 93 | offset: totalCount - 10 94 | })) as Comment[]; 95 | const response = await client.postWithComments( 96 | `post-${postId}`, 97 | undefined, 98 | undefined, 99 | undefined, 100 | 10 101 | ); 102 | expect(response.errors).toBeUndefined(); 103 | expect(response.data.post).not.toBeNull(); 104 | expect(response.data.post.comments.edges).toHaveLength(10); 105 | expect(response.data.post.comments.edges[0].node._id).toEqual( 106 | comments[0]._id 107 | ); 108 | expect(response.data.post.comments.edges[9].node._id).toEqual( 109 | comments[9]._id 110 | ); 111 | }); 112 | 113 | it("list first 10 after the second record", async () => { 114 | const client = new TestClient(process.env.TEST_HOST as string); 115 | const comments = (await CommentModel.findAll({ 116 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 117 | where: { postId }, 118 | limit: 13, 119 | offset: 0 120 | })) as Comment[]; 121 | const afterCursor = comment2Cursor(comments[1]); 122 | const response = await client.postWithComments( 123 | `post-${postId}`, 124 | afterCursor 125 | ); 126 | expect(response.errors).toBeUndefined(); 127 | expect(response.data.post).not.toBeNull(); 128 | expect(response.data.post.comments.edges).toHaveLength(10); 129 | expect(response.data.post.comments.edges[0].node._id).toEqual( 130 | comments[2]._id 131 | ); 132 | expect(response.data.post.comments.edges[9].node._id).toEqual( 133 | comments[11]._id 134 | ); 135 | }); 136 | 137 | it("list first 10 before the last record", async () => { 138 | const client = new TestClient(process.env.TEST_HOST as string); 139 | const totalCount = await CommentModel.count({ where: { postId } }); 140 | const comments = (await CommentModel.findAll({ 141 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 142 | where: { postId }, 143 | limit: 12, 144 | offset: totalCount - 12 145 | })) as Comment[]; 146 | const beforeCursor = comment2Cursor(comments[11]); 147 | const response = await client.postWithComments( 148 | `post-${postId}`, 149 | undefined, 150 | undefined, 151 | beforeCursor, 152 | 10 153 | ); 154 | expect(response.errors).toBeUndefined(); 155 | expect(response.data.post).not.toBeNull(); 156 | expect(response.data.post.comments.edges).toHaveLength(10); 157 | expect(response.data.post.comments.edges[0].node._id).toEqual( 158 | comments[1]._id 159 | ); 160 | expect(response.data.post.comments.edges[9].node._id).toEqual( 161 | comments[10]._id 162 | ); 163 | }); 164 | 165 | it("gives precedence to first argument", async () => { 166 | const client = new TestClient(process.env.TEST_HOST as string); 167 | const comments = (await CommentModel.findAll({ 168 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 169 | where: { postId }, 170 | limit: 10, 171 | offset: 0 172 | })) as Comment[]; 173 | const afterCursor = comment2Cursor(comments[0]); 174 | const beforeCursor = comment2Cursor(comments[9]); 175 | const response = await client.postWithComments( 176 | `post-${postId}`, 177 | afterCursor, 178 | 4, 179 | beforeCursor, 180 | 3 181 | ); 182 | expect(response.errors).toBeUndefined(); 183 | expect(response.data.post).not.toBeNull(); 184 | expect(response.data.post.comments.edges).toHaveLength(3); 185 | expect(response.data.post.comments.edges[0].node._id).toEqual( 186 | comments[2]._id 187 | ); 188 | expect(response.data.post.comments.edges[2].node._id).toEqual( 189 | comments[4]._id 190 | ); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/__test__/comments/createComment.spec.ts: -------------------------------------------------------------------------------- 1 | import * as faker from "faker"; 2 | import { TestClient } from "../util/testClient"; 3 | 4 | describe("Post model plugin", () => { 5 | it("creates post for user", async () => { 6 | const client = new TestClient(process.env.TEST_HOST as string); 7 | const email = faker.internet.email(); 8 | const password = faker.internet.password(); 9 | const name = faker.internet.userName(); 10 | const title = faker.name.title(); 11 | const description = faker.lorem.paragraph(); 12 | const { 13 | data: { 14 | UserRegisterWithEmail: { token } 15 | } 16 | } = await client.register(email, password, name); 17 | const { 18 | data: { 19 | UserCreatePost: { post } 20 | } 21 | } = await client.UserCreatePost(title, description, token); 22 | const response = await client.createComment( 23 | faker.lorem.paragraph(), 24 | token, 25 | post.id 26 | ); 27 | expect(response.errors).toBeUndefined(); 28 | expect(response.data.CreateComment.comment).not.toBeUndefined(); 29 | expect(response.data.CreateComment.comment).not.toBeNull(); 30 | expect(response.data.CreateComment.error).not.toBeDefined(); 31 | expect(response.data.CreateComment.comment.author._id).toEqual( 32 | response.data.CreateComment.comment.post.author._id 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/__test__/post/addPost.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import * as faker from "faker"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { ModelCtor } from "sequelize/types"; 5 | import { Post } from "../../models/Post.model"; 6 | 7 | const PostModel = sequelize.models.Post as ModelCtor; 8 | 9 | describe("add post mutation", () => { 10 | it("inserts post on database", async () => { 11 | const client = new TestClient(process.env.TEST_HOST as string); 12 | const email = faker.internet.email(); 13 | const password = faker.internet.password(); 14 | const name = faker.internet.userName(); 15 | const title = faker.name.title(); 16 | const description = faker.lorem.paragraph(); 17 | const { 18 | data: { 19 | UserRegisterWithEmail: { token } 20 | } 21 | } = await client.register(email, password, name); 22 | const response = await client.UserCreatePost(title, description, token); 23 | expect(response.errors).toBeUndefined(); 24 | expect(response.data.UserCreatePost).not.toBeNull(); 25 | expect(response.data.UserCreatePost.post).not.toBeNull(); 26 | const post = (await PostModel.findByPk( 27 | response.data.UserCreatePost.post._id 28 | )) as Post; 29 | expect(post._id).toEqual(response.data.UserCreatePost.post._id); 30 | expect(post.authorId).toEqual(response.data.UserCreatePost.post.author._id); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/__test__/post/editPost.spec.ts: -------------------------------------------------------------------------------- 1 | import * as faker from "faker"; 2 | import { TestClient } from "../util/testClient"; 3 | 4 | describe("post edit resolver", () => { 5 | it("lets user edit own post", async () => { 6 | const client = new TestClient(process.env.TEST_HOST as string); 7 | const email = faker.internet.email(); 8 | const name = faker.internet.userName(); 9 | const password = faker.internet.password(); 10 | const { 11 | data: { 12 | UserRegisterWithEmail: { token } 13 | } 14 | } = await client.register(email, password, name); 15 | const title1 = faker.name.title(); 16 | const title2 = faker.name.title(); 17 | const { 18 | data: { 19 | UserCreatePost: { 20 | post: { id, _id } 21 | } 22 | } 23 | } = await client.UserCreatePost(title1, faker.lorem.paragraph(), token); 24 | const response = await client.editPost(id, title2, token); 25 | expect(response.errors).toBeUndefined(); 26 | expect(response.data.EditPost.post.title).toEqual(title2); 27 | expect(response.data.EditPost.post._id).toEqual(_id); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/__test__/post/post.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import * as faker from "faker"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { User } from "../../models/User.model"; 5 | import { ModelCtor } from "sequelize/types"; 6 | import { Post } from "../../models/Post.model"; 7 | import * as uuid from "uuid/v4"; 8 | 9 | const UserModel = sequelize.models.User as ModelCtor; 10 | const PostModel = sequelize.models.Post as ModelCtor; 11 | 12 | describe("post query", () => { 13 | it("returns null on post not found", async () => { 14 | const client = new TestClient(process.env.TEST_HOST as string); 15 | const response = await client.post(`post-${uuid()}`); 16 | expect(response.data.post).toBeNull(); 17 | }); 18 | it("returns post on query", async () => { 19 | const client = new TestClient(process.env.TEST_HOST as string); 20 | const email = faker.internet.email(); 21 | const password = faker.internet.password(); 22 | const name = faker.internet.userName(); 23 | const title = faker.name.title(); 24 | const description = faker.lorem.paragraph(); 25 | const user = (await UserModel.create({ 26 | email, 27 | name, 28 | password 29 | })) as User; 30 | const post = (await PostModel.create({ 31 | title, 32 | description, 33 | authorId: user._id 34 | })) as Post; 35 | const response = await client.post(`post-${post._id}`); 36 | expect(response.errors).toBeUndefined(); 37 | expect(response.data).not.toBeUndefined(); 38 | expect(response.data.post).not.toBeNull(); 39 | expect(response.data.post._id).toEqual(post._id); 40 | expect(response.data.post.author._id).toEqual(user._id); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/__test__/post/postModel.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import * as faker from "faker"; 3 | import { User } from "../../models/User.model"; 4 | import { ModelCtor } from "sequelize/types"; 5 | import { Post } from "../../models/Post.model"; 6 | 7 | const UserModel = sequelize.models.User as ModelCtor; 8 | const PostModel = sequelize.models.Post as ModelCtor; 9 | 10 | describe("Post model plugin", () => { 11 | it("creates post for user", async () => { 12 | const email = faker.internet.email(); 13 | const password = faker.internet.password(); 14 | const name = faker.internet.userName(); 15 | const title = faker.name.title(); 16 | const description = faker.lorem.paragraph(); 17 | const user = (await UserModel.create({ 18 | email, 19 | password, 20 | name, 21 | active: true 22 | })) as User; 23 | const post = (await PostModel.create({ 24 | title, 25 | description, 26 | authorId: user._id 27 | })) as Post; 28 | const user2 = (await UserModel.findByPk(user._id, { 29 | include: [Post] 30 | })) as User; 31 | expect(user._id).toEqual(user2._id); 32 | expect(user.email).toEqual(email); 33 | expect(user.password).not.toEqual(password); 34 | expect(user2.posts).toHaveLength(1); 35 | expect(user2.posts[0]._id).toEqual(post._id); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__test__/post/posts.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import * as faker from "faker"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { User } from "../../models/User.model"; 5 | import { ModelCtor } from "sequelize/types"; 6 | import { Post } from "../../models/Post.model"; 7 | import { range } from "lodash"; 8 | import { post2Cursor } from "../../modules/post/types/typeMap"; 9 | 10 | const UserModel = sequelize.models.User as ModelCtor; 11 | const PostModel = sequelize.models.Post as ModelCtor; 12 | 13 | describe("post query", () => { 14 | beforeAll(async () => { 15 | const usersRange = range(0, 20); 16 | const usersPromise = usersRange.map( 17 | () => 18 | UserModel.create({ 19 | email: faker.internet.email(), 20 | name: faker.internet.userName(), 21 | password: faker.internet.password() 22 | }) as Promise 23 | ); 24 | const users = await Promise.all(usersPromise); 25 | const promises = users.map( 26 | user => 27 | PostModel.create({ 28 | title: faker.name.title(), 29 | description: faker.lorem.paragraph(), 30 | authorId: user._id 31 | }) as Promise 32 | ); 33 | await Promise.all(promises); 34 | }); 35 | 36 | it("lists first 10 on default", async () => { 37 | const client = new TestClient(process.env.TEST_HOST as string); 38 | const posts = (await PostModel.findAll({ 39 | limit: 10, 40 | order: [["createdAt", "DESC"], ["authorId", "ASC"]] 41 | })) as Post[]; 42 | const response = await client.feed(); 43 | expect(response.errors).toBeUndefined(); 44 | expect(response.data.feed).not.toBeUndefined(); 45 | expect(response.data.feed).not.toBeNull(); 46 | expect(response.data.feed.edges).toHaveLength(10); 47 | expect(response.data.feed.edges[0].node._id).toEqual(posts[0]._id); 48 | expect(response.data.feed.edges[9].node._id).toEqual(posts[9]._id); 49 | }); 50 | 51 | it("list first 5", async () => { 52 | const client = new TestClient(process.env.TEST_HOST as string); 53 | const posts = (await PostModel.findAll({ 54 | limit: 5, 55 | order: [["createdAt", "DESC"], ["authorId", "ASC"]] 56 | })) as Post[]; 57 | const response = await client.feed(undefined, 5); 58 | expect(response.errors).toBeUndefined(); 59 | expect(response.data.feed).not.toBeNull(); 60 | expect(response.data.feed.edges).toHaveLength(5); 61 | expect(response.data.feed.edges[0].node._id).toEqual(posts[0]._id); 62 | expect(response.data.feed.edges[4].node._id).toEqual(posts[4]._id); 63 | }); 64 | 65 | it("list last 10", async () => { 66 | const client = new TestClient(process.env.TEST_HOST as string); 67 | const totalCount = await PostModel.count(); 68 | const posts = (await PostModel.findAll({ 69 | order: [["createdAt", "DESC"], ["authorId", "ASC"]], 70 | limit: 10, 71 | offset: totalCount - 10 72 | })) as Post[]; 73 | const response = await client.feed(undefined, undefined, undefined, 10); 74 | expect(response.errors).toBeUndefined(); 75 | expect(response.data.feed).not.toBeNull(); 76 | expect(response.data.feed.edges).toHaveLength(10); 77 | expect(response.data.feed.edges[0].node._id).toEqual(posts[0]._id); 78 | expect(response.data.feed.edges[9].node._id).toEqual(posts[9]._id); 79 | }); 80 | 81 | it("list first 10 after the second record", async () => { 82 | const client = new TestClient(process.env.TEST_HOST as string); 83 | const posts = (await PostModel.findAll({ 84 | order: [["createdAt", "DESC"], ["authorId", "ASC"]], 85 | limit: 13, 86 | offset: 0 87 | })) as Post[]; 88 | const afterCursor = post2Cursor(posts[1]); 89 | const response = await client.feed(afterCursor); 90 | expect(response.errors).toBeUndefined(); 91 | expect(response.data.feed).not.toBeNull(); 92 | expect(response.data.feed.edges).toHaveLength(10); 93 | expect(response.data.feed.edges[0].node._id).toEqual(posts[2]._id); 94 | expect(response.data.feed.edges[9].node._id).toEqual(posts[11]._id); 95 | }); 96 | 97 | it("list first 10 before the last record", async () => { 98 | const client = new TestClient(process.env.TEST_HOST as string); 99 | const totalCount = await PostModel.count(); 100 | const posts = (await PostModel.findAll({ 101 | order: [["createdAt", "DESC"], ["authorId", "ASC"]], 102 | limit: 12, 103 | offset: totalCount - 12 104 | })) as Post[]; 105 | const beforeCursor = post2Cursor(posts[11]); 106 | const response = await client.feed(undefined, undefined, beforeCursor, 10); 107 | expect(response.errors).toBeUndefined(); 108 | expect(response.data.feed).not.toBeNull(); 109 | expect(response.data.feed.edges).toHaveLength(10); 110 | expect(response.data.feed.edges[0].node._id).toEqual(posts[1]._id); 111 | expect(response.data.feed.edges[9].node._id).toEqual(posts[10]._id); 112 | }); 113 | 114 | it("gives precedence to first argument", async () => { 115 | const client = new TestClient(process.env.TEST_HOST as string); 116 | const posts = (await PostModel.findAll({ 117 | order: [["createdAt", "DESC"], ["authorId", "ASC"]], 118 | limit: 10, 119 | offset: 0 120 | })) as Post[]; 121 | const afterCursor = post2Cursor(posts[0]); 122 | const beforeCursor = post2Cursor(posts[9]); 123 | const response = await client.feed(afterCursor, 4, beforeCursor, 3); 124 | expect(response.errors).toBeUndefined(); 125 | expect(response.data.feed).not.toBeNull(); 126 | expect(response.data.feed.edges).toHaveLength(3); 127 | expect(response.data.feed.edges[0].node._id).toEqual(posts[2]._id); 128 | expect(response.data.feed.edges[2].node._id).toEqual(posts[4]._id); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/__test__/setup/setup.ts: -------------------------------------------------------------------------------- 1 | import { default as Server } from "../../server"; 2 | import { AddressInfo } from "net"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { Cors } from "../../config/cors"; 5 | 6 | export default async function setup() { 7 | const app = await Server.start({ 8 | cors: Cors, 9 | port: 0 10 | }); 11 | const { port } = app.address() as AddressInfo; 12 | process.env.TEST_HOST = `http://127.0.0.1:${port}`; 13 | console.log(process.env.TEST_HOST); 14 | const UserModel = sequelize.models.User; 15 | await UserModel.destroy({ 16 | truncate: true, 17 | cascade: true 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/__test__/setup/setupJest.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000000); 2 | -------------------------------------------------------------------------------- /src/__test__/shared/node.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { User } from "../../models/User.model"; 4 | import { Post } from "../../models/Post.model"; 5 | import { TestClient } from "../util/testClient"; 6 | import * as faker from "faker"; 7 | import { Comment } from "../../models/Comment.model"; 8 | 9 | const UserModel = sequelize.models.User as ModelCtor; 10 | const PostModel = sequelize.models.Post as ModelCtor; 11 | const CommentModel = sequelize.models.Comment as ModelCtor; 12 | 13 | describe("generic node query", () => { 14 | it("resolves user type", async () => { 15 | const client = new TestClient(process.env.TEST_HOST as string); 16 | const user = (await UserModel.create({ 17 | email: faker.internet.email(), 18 | name: faker.internet.userName(), 19 | password: faker.internet.password() 20 | })) as User; 21 | const id = `user-${user._id}`; 22 | const response = await client.node( 23 | id, 24 | ` 25 | ... on User { 26 | name 27 | email 28 | _id 29 | } 30 | ` 31 | ); 32 | expect(response.errors).toBeUndefined(); 33 | expect(response.data.node).not.toBeNull(); 34 | expect(response.data.node._id).toEqual(user._id); 35 | expect(response.data.node.name).toEqual(user.name); 36 | expect(response.data.node.email).toEqual(user.email); 37 | }); 38 | it("resolves post type", async () => { 39 | const client = new TestClient(process.env.TEST_HOST as string); 40 | const user = (await UserModel.create({ 41 | email: faker.internet.email(), 42 | name: faker.internet.userName(), 43 | password: faker.internet.password() 44 | })) as User; 45 | const post = (await PostModel.create({ 46 | authorId: user._id, 47 | title: faker.name.title(), 48 | description: faker.lorem.paragraph() 49 | })) as Post; 50 | const id = `post-${post._id}`; 51 | const response = await client.node( 52 | id, 53 | ` 54 | ... on Post { 55 | title 56 | description 57 | _id 58 | } 59 | ` 60 | ); 61 | expect(response.errors).toBeUndefined(); 62 | expect(response.data.node).not.toBeNull(); 63 | expect(response.data.node._id).toEqual(post._id); 64 | expect(response.data.node.title).toEqual(post.title); 65 | expect(response.data.node.description).toEqual(post.description); 66 | }); 67 | 68 | it("resolves comment type", async () => { 69 | const client = new TestClient(process.env.TEST_HOST as string); 70 | const user = (await UserModel.create({ 71 | email: faker.internet.email(), 72 | name: faker.internet.userName(), 73 | password: faker.internet.password() 74 | })) as User; 75 | const post = (await PostModel.create({ 76 | authorId: user._id, 77 | title: faker.name.title(), 78 | description: faker.lorem.paragraph() 79 | })) as Post; 80 | const comment = (await CommentModel.create({ 81 | authorId: user._id, 82 | postId: post._id, 83 | comment: faker.lorem.paragraph() 84 | })) as Comment; 85 | const id = `comment-${comment._id}`; 86 | const response = await client.node( 87 | id, 88 | ` 89 | ... on Comment { 90 | _id 91 | comment 92 | } 93 | ` 94 | ); 95 | expect(response.errors).toBeUndefined(); 96 | expect(response.data.node).not.toBeNull(); 97 | expect(response.data.node._id).toEqual(comment._id); 98 | expect(response.data.node.comment).toEqual(comment.comment); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/__test__/user/changePassword.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import { TestClient } from "../util/testClient"; 3 | import * as faker from "faker"; 4 | import { User } from "../../models/User.model"; 5 | import { invalidLogin } from "../../modules/user/types/errorMessages"; 6 | 7 | describe("change password", () => { 8 | it("changes password", async () => { 9 | const UserModel = sequelize.models.User; 10 | const client = new TestClient(process.env.TEST_HOST as string); 11 | const email = faker.internet.email(); 12 | const password = faker.internet.password(); 13 | const password2 = faker.internet.password(); 14 | const name = faker.internet.userName(); 15 | const user = (await UserModel.create({ 16 | email, 17 | password, 18 | name 19 | })) as User; 20 | const { 21 | data: { 22 | UserLoginWithEmail: { token } 23 | } 24 | } = await client.login(email, password); 25 | const response = await client.changePassword(password, password2, token); 26 | expect(response.data.UserChangePassword).not.toBeNull(); 27 | expect(response.data.UserChangePassword.error).toBeNull(); 28 | expect(response.data.UserChangePassword.me._id).toEqual(user._id); 29 | await user.reload(); 30 | expect(user.password).not.toEqual(password2); 31 | const responseLogin = await client.login(email, password2); 32 | expect(responseLogin.data.UserLoginWithEmail).not.toBeNull(); 33 | expect(responseLogin.data.UserLoginWithEmail.error).toBeNull(); 34 | expect(responseLogin.data.UserLoginWithEmail.token).not.toBeNull(); 35 | }); 36 | 37 | it("fails on old password", async () => { 38 | const UserModel = sequelize.models.User; 39 | const client = new TestClient(process.env.TEST_HOST as string); 40 | const email = faker.internet.email(); 41 | const password = faker.internet.password(); 42 | const password2 = faker.internet.password(); 43 | const name = faker.internet.userName(); 44 | const user = (await UserModel.create({ 45 | email, 46 | password, 47 | name 48 | })) as User; 49 | const { 50 | data: { 51 | UserLoginWithEmail: { token } 52 | } 53 | } = await client.login(email, password); 54 | const response = await client.changePassword(password, password2, token); 55 | expect(response.data.UserChangePassword).not.toBeNull(); 56 | expect(response.data.UserChangePassword.error).toBeNull(); 57 | expect(response.data.UserChangePassword.me._id).toEqual(user._id); 58 | const responseLogin = await client.login(email, password); 59 | expect(responseLogin.data.UserLoginWithEmail).not.toBeNull(); 60 | expect(responseLogin.data.UserLoginWithEmail.error).toEqual(invalidLogin); 61 | expect(responseLogin.data.UserLoginWithEmail.token).toBeNull(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/__test__/user/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import { TestClient } from "../util/testClient"; 3 | import * as faker from "faker"; 4 | import { User } from "../../models/User.model"; 5 | import * as jwt from "jsonwebtoken"; 6 | import * as tp from "typed-promisify"; 7 | import { invalidLogin } from "../../modules/user/types/errorMessages"; 8 | 9 | const verify = tp.promisify(jwt.verify); 10 | 11 | describe("login mutation", () => { 12 | it("logs valid user", async () => { 13 | const UserModel = sequelize.models.User; 14 | const client = new TestClient(process.env.TEST_HOST as string); 15 | const email = faker.internet.email(); 16 | const password = faker.internet.password(); 17 | const name = faker.internet.userName(); 18 | const user = (await UserModel.create({ 19 | email, 20 | password, 21 | name 22 | })) as User; 23 | const response = await client.login(email, password); 24 | expect(response.data.UserLoginWithEmail).not.toBeNull(); 25 | expect(response.data.UserLoginWithEmail.error).toBeNull(); 26 | expect(response.data.UserLoginWithEmail.token).not.toBeNull(); 27 | const { userId } = (await verify( 28 | response.data.UserLoginWithEmail.token, 29 | process.env.APP_SECRET || "secret" 30 | )) as any; 31 | expect(userId).toEqual(user._id); 32 | }); 33 | 34 | it("returns error on wrong password", async () => { 35 | const UserModel = sequelize.models.User; 36 | const client = new TestClient(process.env.TEST_HOST as string); 37 | const email = faker.internet.email(); 38 | const password = faker.internet.password(); 39 | const name = faker.internet.userName(); 40 | await UserModel.create({ 41 | email, 42 | password, 43 | name 44 | }); 45 | const response = await client.login( 46 | email, 47 | "wrong passwordlausidhf apçshfç" 48 | ); 49 | expect(response.data.UserLoginWithEmail).not.toBeNull(); 50 | expect(response.data.UserLoginWithEmail.error).not.toBeNull(); 51 | expect(response.data.UserLoginWithEmail.token).toBeNull(); 52 | expect(response.data.UserLoginWithEmail.error).toEqual(invalidLogin); 53 | }); 54 | 55 | it("returns error on invalid email", async () => { 56 | const client = new TestClient(process.env.TEST_HOST as string); 57 | const response = await client.login( 58 | "lgliugliug", 59 | "wrong passwordlausidhf apçshfç" 60 | ); 61 | expect(response.data.UserLoginWithEmail).not.toBeNull(); 62 | expect(response.data.UserLoginWithEmail.error).not.toBeNull(); 63 | expect(response.data.UserLoginWithEmail.token).toBeNull(); 64 | 65 | expect(response.data.UserLoginWithEmail.error).toEqual(invalidLogin); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/__test__/user/me.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import { sequelize } from "../../services/sequelize"; 3 | import * as faker from "faker"; 4 | import { User } from "../../models/User.model"; 5 | 6 | describe("me query", () => { 7 | it("returns null on unauthenticated", async () => { 8 | const client = new TestClient(process.env.TEST_HOST as string); 9 | const response = await client.me("aoçsdhfçqeori"); 10 | expect(response.data.me).toBeNull(); 11 | expect(response.errors).not.toBeNull(); 12 | }); 13 | 14 | it("returns me on authenticated", async () => { 15 | const client = new TestClient(process.env.TEST_HOST as string); 16 | const UserModel = sequelize.models.User; 17 | const email = faker.internet.email(); 18 | const password = faker.internet.password(); 19 | const name = faker.internet.userName(); 20 | const user = (await UserModel.create({ 21 | email, 22 | password, 23 | name 24 | })) as User; 25 | const { 26 | data: { 27 | UserLoginWithEmail: { token } 28 | } 29 | } = await client.login(email, password); 30 | const response = await client.me(token); 31 | expect(response.data.me).not.toBeNull(); 32 | expect(response.data.me._id).toEqual(user._id); 33 | expect(response.data.me._id).toEqual(user._id); 34 | expect(response.data.me.name).toEqual(user.name); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__test__/user/register.spec.ts: -------------------------------------------------------------------------------- 1 | import * as faker from "faker"; 2 | import * as jwt from "jsonwebtoken"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { TestClient } from "../util/testClient"; 5 | import { User } from "../../models/User.model"; 6 | import * as tp from "typed-promisify"; 7 | 8 | const verify = tp.promisify(jwt.verify); 9 | 10 | describe("user registration", () => { 11 | it("registers user with email", async () => { 12 | const UserModel = sequelize.models.User; 13 | const client = new TestClient(process.env.TEST_HOST as string); 14 | const email = faker.internet.email(); 15 | const password = faker.internet.password(); 16 | const name = faker.internet.userName(); 17 | const response = await client.register(email, password, name); 18 | const user = (await UserModel.findOne({ where: { email } })) as User; 19 | expect(user.name).toEqual(name); 20 | expect(response.errors).toBeUndefined(); 21 | expect(response).toHaveProperty("data"); 22 | expect(response.data).toHaveProperty("UserRegisterWithEmail"); 23 | expect(response.data.UserRegisterWithEmail.error).toBeNull(); 24 | expect(response.data.UserRegisterWithEmail.token).not.toBeNull(); 25 | const { userId } = (await verify( 26 | response.data.UserRegisterWithEmail.token, 27 | process.env.APP_SECRET || "secret" 28 | )) as any; 29 | expect(userId).toEqual(user._id); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/__test__/user/user.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import * as faker from "faker"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { User } from "../../models/User.model"; 5 | 6 | const UserModel = sequelize.models.User; 7 | 8 | describe("user query", () => { 9 | it("returns null on user not found", async () => { 10 | const client = new TestClient(process.env.TEST_HOST as string); 11 | const response = await client.user("user-çaçafijçadfivj"); 12 | expect(response.data.user).toBeNull(); 13 | }); 14 | it("returns user data on query", async () => { 15 | const client = new TestClient(process.env.TEST_HOST as string); 16 | const email = faker.internet.email(); 17 | const password = faker.internet.password(); 18 | const name = faker.internet.userName(); 19 | const user = (await UserModel.create({ 20 | email, 21 | name, 22 | password 23 | })) as User; 24 | const response = await client.user(`user-${user._id}`); 25 | expect(response.data.user).not.toBeNull(); 26 | expect(response.data.user._id).toEqual(user._id); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/__test__/user/userModel.spec.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../../services/sequelize"; 2 | import * as faker from "faker"; 3 | import { User } from "../../models/User.model"; 4 | 5 | const UserModel = sequelize.models.User; 6 | 7 | describe("Sequelize plugin", () => { 8 | it("creates records", async () => { 9 | const email = faker.internet.email(); 10 | const password = faker.internet.password(); 11 | const name = faker.internet.userName(); 12 | const user = (await UserModel.create({ 13 | email, 14 | password, 15 | name, 16 | active: true 17 | })) as User; 18 | const user2 = (await UserModel.findOne({ 19 | where: { _id: user._id } 20 | })) as User; 21 | expect(user._id).toEqual(user2._id); 22 | expect(user.email).toEqual(email); 23 | expect(user.password).not.toEqual(password); 24 | }); 25 | 26 | it("deletes records", async () => { 27 | const email = faker.internet.email(); 28 | const password = faker.internet.password(); 29 | const name = faker.internet.userName(); 30 | const user = (await UserModel.create({ 31 | email, 32 | password, 33 | name, 34 | active: true 35 | })) as User; 36 | await user.destroy(); 37 | const user2 = await UserModel.findOne({ 38 | where: { _id: user._id } 39 | }); 40 | expect(user2).toBeNull(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/__test__/user/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from "../util/testClient"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { sequelize } from "../../services/sequelize"; 4 | import { User } from "../../models/User.model"; 5 | import * as faker from "faker"; 6 | import { range } from "lodash"; 7 | import { string2Cursor } from "../../util/typeMap"; 8 | 9 | const UserModel = sequelize.models.User as ModelCtor; 10 | 11 | describe("Users query", () => { 12 | beforeAll(async () => { 13 | const request = range(0, 20).map(() => 14 | UserModel.create({ 15 | email: faker.internet.email(), 16 | password: faker.internet.password(), 17 | name: faker.internet.userName() 18 | }) 19 | ); 20 | await Promise.all(request); 21 | }); 22 | 23 | it("lists first 10 on default", async () => { 24 | const client = new TestClient(process.env.TEST_HOST as string); 25 | const response = await client.users(); 26 | expect(response.errors).toBeUndefined(); 27 | expect(response.data.users).not.toBeNull(); 28 | expect(response.data.users.edges).toHaveLength(10); 29 | }); 30 | 31 | it("list first 5", async () => { 32 | const client = new TestClient(process.env.TEST_HOST as string); 33 | const users = (await UserModel.findAll({ 34 | order: [["name", "ASC"]], 35 | limit: 5, 36 | offset: 0 37 | })) as User[]; 38 | const response = await client.users(undefined, 5); 39 | expect(response.errors).toBeUndefined(); 40 | expect(response.data.users).not.toBeNull(); 41 | expect(response.data.users.edges).toHaveLength(5); 42 | expect(response.data.users.edges[0].node._id).toEqual(users[0]._id); 43 | expect(response.data.users.edges[4].node._id).toEqual(users[4]._id); 44 | }); 45 | 46 | it("list last 10", async () => { 47 | const client = new TestClient(process.env.TEST_HOST as string); 48 | const totalCount = await UserModel.count(); 49 | const users = (await UserModel.findAll({ 50 | order: [["name", "ASC"]], 51 | limit: 10, 52 | offset: totalCount - 10 53 | })) as User[]; 54 | const response = await client.users(undefined, undefined, undefined, 10); 55 | expect(response.errors).toBeUndefined(); 56 | expect(response.data.users).not.toBeNull(); 57 | expect(response.data.users.edges).toHaveLength(10); 58 | expect(response.data.users.edges[0].node._id).toEqual(users[0]._id); 59 | expect(response.data.users.edges[9].node._id).toEqual(users[9]._id); 60 | }); 61 | 62 | it("list first 10 after the second record", async () => { 63 | const client = new TestClient(process.env.TEST_HOST as string); 64 | const users = (await UserModel.findAll({ 65 | order: [["name", "ASC"]], 66 | limit: 13, 67 | offset: 0 68 | })) as User[]; 69 | const afterCursor = string2Cursor(users[1].name, "user-name-"); 70 | const response = await client.users(afterCursor); 71 | expect(response.errors).toBeUndefined(); 72 | expect(response.data.users).not.toBeNull(); 73 | expect(response.data.users.edges).toHaveLength(10); 74 | expect(response.data.users.edges[0].node._id).toEqual(users[2]._id); 75 | expect(response.data.users.edges[9].node._id).toEqual(users[11]._id); 76 | }); 77 | it("list first 10 before the last record", async () => { 78 | const client = new TestClient(process.env.TEST_HOST as string); 79 | const totalCount = await UserModel.count(); 80 | const users = (await UserModel.findAll({ 81 | order: [["name", "ASC"]], 82 | limit: 12, 83 | offset: totalCount - 12 84 | })) as User[]; 85 | const beforeCursor = string2Cursor(users[11].name, "user-name-"); 86 | const response = await client.users(undefined, undefined, beforeCursor, 10); 87 | expect(response.errors).toBeUndefined(); 88 | expect(response.data.users).not.toBeNull(); 89 | expect(response.data.users.edges).toHaveLength(10); 90 | expect(response.data.users.edges[0].node._id).toEqual(users[1]._id); 91 | expect(response.data.users.edges[9].node._id).toEqual(users[10]._id); 92 | }); 93 | it("list first 10 before the last record", async () => { 94 | const client = new TestClient(process.env.TEST_HOST as string); 95 | const users = (await UserModel.findAll({ 96 | order: [["name", "ASC"]], 97 | limit: 10, 98 | offset: 0 99 | })) as User[]; 100 | const afterCursor = string2Cursor(users[0].name, "user-name-"); 101 | const beforeCursor = string2Cursor(users[9].name, "user-name-"); 102 | const response = await client.users(afterCursor, 4, beforeCursor, 3); 103 | expect(response.errors).toBeUndefined(); 104 | expect(response.data.users).not.toBeNull(); 105 | expect(response.data.users.edges).toHaveLength(3); 106 | expect(response.data.users.edges[0].node._id).toEqual(users[2]._id); 107 | expect(response.data.users.edges[2].node._id).toEqual(users[4]._id); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/__test__/util/testClient.ts: -------------------------------------------------------------------------------- 1 | import * as rp from "request-promise"; 2 | import { CookieJar } from "request"; 3 | 4 | export class TestClient { 5 | options: { 6 | jar: CookieJar; 7 | withCredentials: boolean; 8 | json: boolean; 9 | }; 10 | 11 | constructor(public url: string) { 12 | this.options = { 13 | withCredentials: true, 14 | jar: rp.jar(), 15 | json: true 16 | }; 17 | } 18 | 19 | async register(email: string, password: string, name: string) { 20 | return rp.post(this.url, { 21 | ...this.options, 22 | body: { 23 | query: ` 24 | mutation { 25 | UserRegisterWithEmail( 26 | input: { 27 | email: "${email}", 28 | password: "${password}", 29 | name: "${name}" 30 | } 31 | ) { 32 | token 33 | error 34 | clientMutationId 35 | } 36 | }` 37 | } 38 | }); 39 | } 40 | 41 | async login(email: string, password: string) { 42 | return rp.post(this.url, { 43 | ...this.options, 44 | body: { 45 | query: ` 46 | mutation { 47 | UserLoginWithEmail( 48 | input: { 49 | email: "${email}", 50 | password: "${password}" 51 | } 52 | ) { 53 | token 54 | error 55 | clientMutationId 56 | } 57 | }` 58 | } 59 | }); 60 | } 61 | 62 | async changePassword( 63 | oldPassword: string, 64 | newPassword: string, 65 | token: string 66 | ) { 67 | return rp.post(this.url, { 68 | ...this.options, 69 | headers: { 70 | Authorization: `Bearer ${token}` 71 | }, 72 | body: { 73 | query: ` 74 | mutation { 75 | UserChangePassword( 76 | input: { 77 | oldPassword: "${oldPassword}", 78 | password: "${newPassword}" 79 | } 80 | ) { 81 | me { 82 | id 83 | name 84 | email 85 | _id 86 | } 87 | error 88 | clientMutationId 89 | } 90 | }` 91 | } 92 | }); 93 | } 94 | 95 | async me(token: string) { 96 | return rp.post(this.url, { 97 | ...this.options, 98 | headers: { 99 | Authorization: `Bearer ${token}` 100 | }, 101 | body: { 102 | query: ` 103 | { 104 | me { 105 | _id 106 | id 107 | name 108 | email 109 | } 110 | } 111 | ` 112 | } 113 | }); 114 | } 115 | 116 | async user(id: string) { 117 | return rp.post(this.url, { 118 | ...this.options, 119 | body: { 120 | query: ` 121 | { 122 | user(id: "${id}") { 123 | _id 124 | id 125 | name 126 | email 127 | } 128 | }` 129 | } 130 | }); 131 | } 132 | 133 | async users( 134 | after?: string | undefined, 135 | first?: number | undefined, 136 | before?: string | undefined, 137 | last?: number | undefined, 138 | search?: string | undefined 139 | ) { 140 | const query = ` 141 | { 142 | users ${ 143 | after || first || before || last 144 | ? `( 145 | ${after ? `after: "${after}"` : ""} 146 | ${first ? `first: ${first}` : ""} 147 | ${before ? `before: "${before}"` : ""} 148 | ${last ? `last: ${last}` : ""} 149 | ${search ? `search: "${search}"` : ""} 150 | ) 151 | ` 152 | : "" 153 | } { 154 | count 155 | totalCount 156 | startCursorOffset 157 | endCursorOffset 158 | pageInfo { 159 | hasNextPage 160 | hasPreviousPage 161 | startCursor 162 | endCursor 163 | } 164 | edges { 165 | node { 166 | id 167 | _id 168 | name 169 | email 170 | } 171 | cursor 172 | } 173 | } 174 | }`; 175 | return rp.post(this.url, { 176 | ...this.options, 177 | body: { 178 | query 179 | } 180 | }); 181 | } 182 | 183 | async post(id: string) { 184 | return rp.post(this.url, { 185 | ...this.options, 186 | body: { 187 | query: ` 188 | { 189 | post(id: "${id}") { 190 | _id 191 | id 192 | description 193 | title 194 | author { 195 | id 196 | _id 197 | name 198 | email 199 | } 200 | } 201 | }` 202 | } 203 | }); 204 | } 205 | 206 | async UserCreatePost(title: string, description: string, token: string) { 207 | return rp.post(this.url, { 208 | ...this.options, 209 | headers: { 210 | Authorization: `Bearer ${token}` 211 | }, 212 | body: { 213 | query: ` 214 | mutation { 215 | UserCreatePost(input: { 216 | title: "${title}" 217 | description: "${description}" 218 | }) { 219 | error 220 | post { 221 | id 222 | _id 223 | title 224 | description 225 | author { 226 | id 227 | _id 228 | name 229 | email 230 | } 231 | } 232 | } 233 | }` 234 | } 235 | }); 236 | } 237 | 238 | async feed( 239 | after?: string | undefined, 240 | first?: number | undefined, 241 | before?: string | undefined, 242 | last?: number | undefined 243 | ) { 244 | const query = ` 245 | { 246 | feed ${ 247 | after || first || before || last 248 | ? `( 249 | ${after ? `after: "${after}"` : ""} 250 | ${first ? `first: ${first}` : ""} 251 | ${before ? `before: "${before}"` : ""} 252 | ${last ? `last: ${last}` : ""} 253 | ) 254 | ` 255 | : "" 256 | } { 257 | count 258 | totalCount 259 | startCursorOffset 260 | endCursorOffset 261 | pageInfo { 262 | hasNextPage 263 | hasPreviousPage 264 | startCursor 265 | endCursor 266 | } 267 | edges { 268 | node { 269 | id 270 | _id 271 | description 272 | title 273 | } 274 | cursor 275 | } 276 | } 277 | }`; 278 | return rp.post(this.url, { 279 | ...this.options, 280 | body: { 281 | query 282 | } 283 | }); 284 | } 285 | 286 | async node(id: string, fields?: string) { 287 | return rp.post(this.url, { 288 | ...this.options, 289 | body: { 290 | query: ` 291 | { 292 | node(id: "${id}") { 293 | id 294 | ${fields} 295 | } 296 | } 297 | ` 298 | } 299 | }); 300 | } 301 | 302 | async editPost(id: string, title: string, token: string) { 303 | return rp.post(this.url, { 304 | ...this.options, 305 | headers: { 306 | Authorization: `Bearer ${token}` 307 | }, 308 | body: { 309 | query: `mutation { 310 | EditPost(input: {id: "${id}", title: "${title}"}) { 311 | post { 312 | _id 313 | id 314 | title 315 | description 316 | } 317 | } 318 | }` 319 | } 320 | }); 321 | } 322 | 323 | async createComment(comment: string, token: string, postId: string) { 324 | return rp.post(this.url, { 325 | ...this.options, 326 | headers: { 327 | Authorization: `Bearer ${token}` 328 | }, 329 | body: { 330 | query: `mutation { 331 | CreateComment(input: { 332 | postId: "${postId}" 333 | comment: "${comment}" 334 | }) { 335 | comment { 336 | id 337 | _id 338 | comment 339 | post { 340 | id 341 | _id 342 | title 343 | description 344 | author { 345 | id 346 | _id 347 | name 348 | email 349 | } 350 | } 351 | author { 352 | id 353 | _id 354 | name 355 | email 356 | } 357 | } 358 | } 359 | }` 360 | } 361 | }); 362 | } 363 | async postWithComments( 364 | id: string, 365 | after?: string | undefined, 366 | first?: number | undefined, 367 | before?: string | undefined, 368 | last?: number | undefined 369 | ) { 370 | const query = `{ 371 | post(id: "${id}") { 372 | _id 373 | id 374 | description 375 | title 376 | author { 377 | id 378 | _id 379 | name 380 | email 381 | } 382 | comments ${ 383 | after || first || before || last 384 | ? `( 385 | ${after ? `after: "${after}"` : ""} 386 | ${first ? `first: ${first}` : ""} 387 | ${before ? `before: "${before}"` : ""} 388 | ${last ? `last: ${last}` : ""} 389 | ) 390 | ` 391 | : "" 392 | } { 393 | edges { 394 | node { 395 | _id 396 | comment 397 | id 398 | author { 399 | id 400 | _id 401 | name 402 | email 403 | } 404 | } 405 | } 406 | } 407 | } 408 | }`; 409 | return rp.post(this.url, { 410 | ...this.options, 411 | body: { 412 | query 413 | } 414 | }); 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /src/config/cors.ts: -------------------------------------------------------------------------------- 1 | export const Cors = { 2 | credentials: true, 3 | origin: 4 | process.env.NODE_ENV !== "production" 5 | ? "*" 6 | : (process.env.FRONTEND_HOST as string) 7 | }; 8 | -------------------------------------------------------------------------------- /src/config/sequelize.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { Dialect } from "sequelize"; 3 | import { SequelizeOptions } from "sequelize-typescript"; 4 | 5 | const options: SequelizeOptions = { 6 | username: process.env.DB_USERNAME || "postgres", 7 | password: process.env.DB_PASSWORD || undefined, 8 | database: 9 | process.env.NODE_ENV !== "test" 10 | ? process.env.DB_DATABASE || "relay_modern_development" 11 | : "relay_modern_test", 12 | host: 13 | process.env.DB_HOSTNAME || 14 | (process.env.NODE_ENV === "test" ? "0.0.0.0" : "db"), 15 | port: Number.parseInt(process.env.DB_PORT || "5432", 10), 16 | dialect: (process.env.DB_DIALECT as Dialect) || "postgres", 17 | logging: process.env.NODE_ENV !== "test" ? console.log : false 18 | }; 19 | 20 | export default options; 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import "dotenv/config"; 3 | import { default as Server } from "./server"; 4 | import { Cors as cors } from "./config/cors"; 5 | 6 | const port = process.env.PORT || "5555"; 7 | Server.start({ 8 | cors, 9 | port 10 | }).then(() => { 11 | console.log("app is running on port "); 12 | }); 13 | -------------------------------------------------------------------------------- /src/models/Comment.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | Model, 4 | BeforeCreate, 5 | Column, 6 | ForeignKey, 7 | BelongsTo 8 | } from "sequelize-typescript"; 9 | import * as uuid from "uuid/v4"; 10 | import { User } from "./User.model"; 11 | import { Post } from "./Post.model"; 12 | 13 | @Table({ 14 | tableName: "comments" 15 | }) 16 | export class Comment extends Model { 17 | @BeforeCreate({ name: "giveId" }) static async giveId(instance: Comment) { 18 | instance._id = uuid(); 19 | } 20 | 21 | @Column({ 22 | primaryKey: true 23 | }) 24 | // tslint:disable-next-line: variable-name 25 | _id: string; 26 | 27 | @Column comment: string; 28 | @ForeignKey(() => User) @Column authorId: string; 29 | 30 | @ForeignKey(() => Post) @Column postId: string; 31 | 32 | @BelongsTo(() => Post) post: Post; 33 | @BelongsTo(() => User) author: User; 34 | } 35 | -------------------------------------------------------------------------------- /src/models/Post.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | Column, 4 | Model, 5 | BeforeCreate, 6 | BelongsTo, 7 | ForeignKey, 8 | HasMany 9 | } from "sequelize-typescript"; 10 | import * as uuid from "uuid/v4"; 11 | import { User } from "./User.model"; 12 | import { Comment } from "./Comment.model"; 13 | 14 | @Table({ 15 | tableName: "posts" 16 | }) 17 | export class Post extends Model { 18 | @BeforeCreate({ name: "giveId" }) static async giveId(instance: Post) { 19 | instance._id = uuid(); 20 | } 21 | 22 | @Column({ 23 | primaryKey: true 24 | }) 25 | // tslint:disable-next-line: variable-name 26 | _id: string; 27 | @Column title: string; 28 | @Column description: string; 29 | 30 | @ForeignKey(() => User) 31 | @Column 32 | authorId: string; 33 | 34 | @BelongsTo(() => User) 35 | author: User; 36 | 37 | @HasMany(() => Comment) comments: Comment[]; 38 | } 39 | -------------------------------------------------------------------------------- /src/models/User.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | Column, 4 | Model, 5 | BeforeCreate, 6 | HasMany 7 | } from "sequelize-typescript"; 8 | import * as uuid from "uuid/v4"; 9 | import * as bcrypt from "bcryptjs"; 10 | import { Post } from "./Post.model"; 11 | 12 | @Table({ 13 | tableName: "users", 14 | timestamps: false 15 | }) 16 | export class User extends Model { 17 | @BeforeCreate({ name: "giveId" }) static async giveId(instance: User) { 18 | instance._id = uuid(); 19 | instance.password = await bcrypt.hash(instance.password, 10); 20 | } 21 | 22 | @Column name: string; 23 | @Column({ 24 | primaryKey: true 25 | }) 26 | // tslint:disable-next-line: variable-name 27 | _id: string; 28 | @Column email: string; 29 | @Column password: string; 30 | @Column active: boolean; 31 | 32 | @HasMany(() => Post, "authorId") 33 | posts: Post[]; 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/comment/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { createComment as CreateComment } from "./resolvers/createComment"; 2 | import { author } from "./resolvers/commentAuthor"; 3 | import { post } from "./resolvers/commentPost"; 4 | import { comments } from "./resolvers/comments"; 5 | 6 | export const resolvers = { 7 | Mutation: { 8 | CreateComment 9 | }, 10 | Comment: { 11 | post, 12 | author 13 | }, 14 | Post: { 15 | comments 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/modules/comment/resolvers/commentAuthor.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { User } from "../../../models/User.model"; 4 | import { user2IUser } from "../../user/types/typeMap"; 5 | 6 | export const author: Resolver = async ( 7 | obj: Partial, 8 | _, 9 | { sequelize } 10 | ) => { 11 | const UserModel = sequelize.models.User as ModelCtor; 12 | const user = (await UserModel.findByPk(obj.authorId)) as User; 13 | return user2IUser(user); 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/comment/resolvers/commentPost.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { Post } from "../../../models/Post.model"; 4 | import { post2IPost } from "../../post/types/typeMap"; 5 | 6 | export const post: Resolver = async ( 7 | obj: Partial, 8 | _, 9 | { sequelize } 10 | ) => { 11 | const PostModel = sequelize.models.Post as ModelCtor; 12 | const postObj = (await PostModel.findByPk(obj.postId as string)) as Post; 13 | return post2IPost(postObj); 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/comment/resolvers/comments.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { Comment } from "../../../models/Comment.model"; 4 | import { 5 | parseFirstLast, 6 | calculateOffstetLimit, 7 | getPageInfo 8 | } from "../../../util/connectionUtils"; 9 | import { cursor2Comment, comment2PostAuthorEdge } from "../types/typeMap"; 10 | import { Op } from "sequelize"; 11 | 12 | export const cursor2OffsetWithDefault = async ( 13 | cursor: string | null | undefined, 14 | defaultValue: number, 15 | model: ModelCtor 16 | ): Promise => { 17 | if (!cursor) { 18 | return defaultValue; 19 | } 20 | const { authorId, postId, createdAt } = cursor2Comment(cursor); 21 | const countCreatedAtPromise = model.count({ 22 | where: { 23 | postId, 24 | createdAt: { 25 | [Op.lt]: createdAt 26 | } 27 | } 28 | }); 29 | const countSameCreationAtPromise = model.count({ 30 | where: { 31 | postId, 32 | createdAt: { 33 | [Op.eq]: createdAt 34 | }, 35 | authorId: { 36 | [Op.lt]: authorId 37 | } 38 | } 39 | }); 40 | const counts = await Promise.all([ 41 | countCreatedAtPromise, 42 | countSameCreationAtPromise 43 | ]); 44 | return counts[0] + counts[1]; 45 | }; 46 | 47 | export const comments: Resolver = async ( 48 | parent: Partial, 49 | args: GQL.ICommentsOnPostArguments, 50 | { sequelize } 51 | ) => { 52 | const CommentModel = sequelize.models.Comment as ModelCtor; 53 | const where = { postId: parent._id as string }; 54 | const { first, last } = parseFirstLast(args.first, args.last); 55 | const { before, after } = args; 56 | const totalCount = await CommentModel.count({ where }); 57 | const afterOffset = await cursor2OffsetWithDefault(after, -1, CommentModel); 58 | const beforeOffset = await cursor2OffsetWithDefault( 59 | before, 60 | totalCount, 61 | CommentModel 62 | ); 63 | const { 64 | safeLimit, 65 | skip, 66 | startOffset, 67 | endCursorOffset 68 | } = calculateOffstetLimit(first, last, totalCount, afterOffset, beforeOffset); 69 | const commentList = (await CommentModel.findAll({ 70 | limit: safeLimit, 71 | offset: skip, 72 | order: [["createdAt", "ASC"], ["authorId", "ASC"]], 73 | where 74 | })) as Comment[]; 75 | const edges = commentList.map(post => comment2PostAuthorEdge(post)); 76 | const firstEdge = edges[0]; 77 | const lastEdge = edges[edges.length - 1]; 78 | const pageInfo = getPageInfo( 79 | firstEdge, 80 | lastEdge, 81 | startOffset, 82 | endCursorOffset, 83 | totalCount 84 | ); 85 | const result = { 86 | edges, 87 | count: edges.length, 88 | totalCount, 89 | endCursorOffset, 90 | startCursorOffset: startOffset, 91 | pageInfo 92 | }; 93 | return result; 94 | }; 95 | -------------------------------------------------------------------------------- /src/modules/comment/resolvers/createComment.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { graphqIdToId } from "../../../util/graphqlId"; 3 | import { idPrefix } from "../../post/types/typeMap"; 4 | import { ModelCtor } from "sequelize-typescript"; 5 | import { Comment } from "../../../models/Comment.model"; 6 | import { comment2IComment } from "../types/typeMap"; 7 | import { applyMiddleware } from "../../../util/applyMiddleware"; 8 | import { authGraphqlMiddleware } from "../../middleware/auth"; 9 | 10 | export const createComment: Resolver = applyMiddleware( 11 | authGraphqlMiddleware, 12 | async ( 13 | _, 14 | { 15 | input: { comment, postId, clientMutationId } 16 | }: GQL.ICreateCommentOnMutationArguments, 17 | { sequelize, userId } 18 | ) => { 19 | const id = graphqIdToId(postId, idPrefix); 20 | const CommentModel = sequelize.models.Comment as ModelCtor; 21 | const commentObj = (await CommentModel.create({ 22 | authorId: userId, 23 | postId: id, 24 | comment 25 | })) as Comment; 26 | return { 27 | comment: comment2IComment(commentObj), 28 | error: null, 29 | clientMutationId: clientMutationId || null 30 | }; 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /src/modules/comment/schema.graphql: -------------------------------------------------------------------------------- 1 | type Comment implements Node { 2 | id: ID! 3 | _id: String! 4 | authorId: String! 5 | postId: String 6 | comment: String! 7 | author: User! 8 | post: Post! 9 | } 10 | 11 | type PostCommentConnection { 12 | count: Int! 13 | totalCount: Int! 14 | startCursorOffset: Int! 15 | endCursorOffset: Int! 16 | pageInfo: PageInfoExtended! 17 | edges: [PostCommentEdge]! 18 | } 19 | 20 | type PostCommentEdge { 21 | node: Comment 22 | cursor: String 23 | } 24 | 25 | type Mutation { 26 | CreateComment(input: CreateCommentInput!): CreateCommentPayload 27 | } 28 | 29 | input CreateCommentInput { 30 | postId: String! 31 | comment: String! 32 | clientMutationId: String 33 | } 34 | 35 | type CreateCommentPayload { 36 | comment: Comment 37 | clientMutationId: String 38 | error: String 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/comment/types/typeMap.ts: -------------------------------------------------------------------------------- 1 | import { model2Node, Node2ModelResolver } from "../../../types/graphql-utils"; 2 | import { Comment } from "../../../models/Comment.model"; 3 | import { string2Cursor, cursor2String } from "../../../util/typeMap"; 4 | import { idToGraphqlId } from "../../../util/graphqlId"; 5 | 6 | export const idPrefix = "comment"; 7 | export const postCommentCursorPrefix = "post-comment-user-createdAt-"; 8 | export const modelName = "Comment"; 9 | export const resolveType = "Comment"; 10 | 11 | export const comment2IComment: model2Node = comment => ({ 12 | id: idToGraphqlId(comment._id, idPrefix), 13 | _id: comment._id, 14 | comment: comment.comment, 15 | postId: comment.postId, 16 | authorId: comment.authorId 17 | }); 18 | 19 | export const comment2PostAuthorEdge = (comment: Comment) => ({ 20 | node: comment2IComment(comment), 21 | cursor: comment2Cursor(comment) 22 | }); 23 | 24 | export const comment2Cursor = (comment: Comment) => 25 | string2Cursor( 26 | `${comment.postId}||${ 27 | comment.authorId 28 | }||${(comment.createdAt as Date).getTime()}`, 29 | postCommentCursorPrefix 30 | ); 31 | 32 | export const cursor2Comment = (cursor: string) => { 33 | const data = cursor2String(cursor, postCommentCursorPrefix).split("||"); 34 | return { 35 | createdAt: new Date(Number.parseInt(data[2], 10)), 36 | postId: data[0], 37 | authorId: data[1] 38 | }; 39 | }; 40 | 41 | export const CommentNode2ModelResolver: Node2ModelResolver< 42 | Comment, 43 | GQL.IComment 44 | > = { 45 | idPrefix, 46 | model2Interface: comment2IComment, 47 | modelName: "Comment", 48 | resolveType: "Comment" 49 | }; 50 | -------------------------------------------------------------------------------- /src/modules/courseUtil/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { User } from "../../models/User.model"; 4 | import { user2IUser } from "../../modules/user/types/typeMap"; 5 | import { Op } from "sequelize"; 6 | 7 | export const resolvers: ResolverMap = { 8 | Query: { 9 | randomUser: async (_, __, { sequelize }) => { 10 | const UserModel = sequelize.models.User as ModelCtor; 11 | const user = (await UserModel.findOne({ 12 | where: { name: { [Op.iLike]: "%a%" } } 13 | })) as User; 14 | return user2IUser(user); 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/modules/courseUtil/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | randomUser: User! 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from "graphql-tools"; 2 | import { mergeResolvers } from "merge-graphql-schemas"; 3 | import { mergeSchemas } from "./mergeSchemas"; 4 | import * as path from "path"; 5 | import * as glob from "glob"; 6 | 7 | export const genSchema = () => { 8 | const pathToModules = path.join(__dirname); 9 | const graphqlTypes = mergeSchemas(pathToModules); 10 | const resolvers = mergeResolvers( 11 | glob 12 | .sync(`${pathToModules}/**/resolvers.?s`) 13 | .map(resolver => require(resolver).resolvers) 14 | ); 15 | return makeExecutableSchema({ 16 | typeDefs: graphqlTypes, 17 | resolvers, 18 | resolverValidationOptions: { 19 | requireResolversForResolveType: false 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/modules/mergeSchemas.ts: -------------------------------------------------------------------------------- 1 | import { mergeTypes } from "merge-graphql-schemas"; 2 | import * as glob from "glob"; 3 | import * as fs from "fs"; 4 | 5 | export const mergeSchemas = (pathToModules: string) => 6 | mergeTypes( 7 | glob 8 | .sync(`${pathToModules}/**/*.graphql`) 9 | .map(x => fs.readFileSync(x, { encoding: "utf8" })) 10 | ); 11 | -------------------------------------------------------------------------------- /src/modules/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { GraphqlMiddleware } from "../../types/graphql-utils"; 2 | import { invalidAuthentication } from "./errorMessages"; 3 | import { verifyJwt } from "../../util/verifyJwt"; 4 | 5 | export const authGraphqlMiddleware: GraphqlMiddleware = async ( 6 | resolver, 7 | parent, 8 | args, 9 | context, 10 | info, 11 | params 12 | ) => { 13 | let throwError = true; 14 | if (params) { 15 | throwError = params.throwError; 16 | } 17 | const jwt = await verifyJwt(context.request); 18 | if (jwt === null) { 19 | if (throwError) { 20 | throw new Error(invalidAuthentication); 21 | } 22 | return resolver(parent, args, context, info); 23 | } 24 | const { userId } = jwt; 25 | return resolver(parent, args, { ...context, userId }, info); 26 | }; 27 | -------------------------------------------------------------------------------- /src/modules/middleware/errorMessages.ts: -------------------------------------------------------------------------------- 1 | 2 | export const invalidAuthentication = "Invalid authentication"; 3 | -------------------------------------------------------------------------------- /src/modules/post/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../types/graphql-utils"; 2 | import { post } from "./resolvers/post"; 3 | import { PostUser } from "./resolvers/user"; 4 | import { UserCreatePost } from "./resolvers/userCreatePost"; 5 | import { EditPost } from "./resolvers/editPost"; 6 | import { feed } from "./resolvers/feed"; 7 | 8 | export const resolvers: ResolverMap = { 9 | Mutation: { 10 | UserCreatePost, 11 | EditPost 12 | }, 13 | Query: { 14 | post, 15 | feed 16 | }, 17 | Post: { 18 | author: PostUser 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/post/resolvers/editPost.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { applyMiddleware } from "../../../util/applyMiddleware"; 3 | import { authGraphqlMiddleware } from "../../middleware/auth"; 4 | import { ModelCtor } from "sequelize-typescript"; 5 | import { Post } from "../../../models/Post.model"; 6 | import { ForbiddenPostEdit } from "../types/errorMessages"; 7 | import { graphqIdToId } from "../../../util/graphqlId"; 8 | import { idPrefix, post2IPost } from "../types/typeMap"; 9 | 10 | export const EditPost: Resolver = applyMiddleware( 11 | authGraphqlMiddleware, 12 | async ( 13 | _, 14 | { 15 | input: { id: graphId, title, clientMutationId } 16 | }: GQL.IEditPostOnMutationArguments, 17 | { sequelize, userId } 18 | ) => { 19 | const PostModel = sequelize.models.Post as ModelCtor; 20 | const id = graphqIdToId(graphId, idPrefix); 21 | const post = (await PostModel.findByPk(id)) as Post; 22 | if (post.authorId !== userId) { 23 | return { 24 | error: ForbiddenPostEdit, 25 | post: null, 26 | clientMutationId: clientMutationId || null 27 | }; 28 | } 29 | post.title = title; 30 | await post.save(); 31 | return { 32 | error: null, 33 | post: post2IPost(post), 34 | clientMutationId: clientMutationId || null 35 | }; 36 | } 37 | ); 38 | -------------------------------------------------------------------------------- /src/modules/post/resolvers/feed.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor, Op } from "sequelize"; 3 | import { Post } from "../../../models/Post.model"; 4 | import { post2Edge, cursor2Post } from "../types/typeMap"; 5 | import { 6 | parseFirstLast, 7 | calculateOffstetLimit, 8 | getPageInfo 9 | } from "../../../util/connectionUtils"; 10 | 11 | const cursor2OffsetWithDefault = async ( 12 | after: string | null | undefined, 13 | defaultValue: number, 14 | PostModel: ModelCtor 15 | ): Promise => { 16 | if (!after) { 17 | return defaultValue; 18 | } 19 | const nameAfter = cursor2Post(after); 20 | const countCreatedAtPromise = PostModel.count({ 21 | where: { 22 | createdAt: { 23 | [Op.gt]: nameAfter.createdAt 24 | } 25 | } 26 | }); 27 | const countNamePromise = PostModel.count({ 28 | where: { 29 | createdAt: { 30 | [Op.eq]: nameAfter.createdAt 31 | }, 32 | authorId: { 33 | [Op.lt]: nameAfter.authorId 34 | } 35 | } 36 | }); 37 | const [countAt, countName] = await Promise.all([ 38 | countCreatedAtPromise, 39 | countNamePromise 40 | ]); 41 | return countAt + countName; 42 | }; 43 | 44 | export const feed: Resolver = async ( 45 | _, 46 | args: GQL.IFeedOnQueryArguments, 47 | { sequelize } 48 | ) => { 49 | const PostModel = sequelize.models.Post as ModelCtor; 50 | const { before, after } = args; 51 | const { first, last } = parseFirstLast(args.first, args.last); 52 | const totalCount = await PostModel.count(); 53 | const afterOffset = await cursor2OffsetWithDefault(after, -1, PostModel); 54 | const beforeOffset = await cursor2OffsetWithDefault( 55 | before, 56 | totalCount, 57 | PostModel 58 | ); 59 | const { 60 | safeLimit, 61 | skip, 62 | startOffset, 63 | endCursorOffset 64 | } = calculateOffstetLimit(first, last, totalCount, afterOffset, beforeOffset); 65 | const posts = (await PostModel.findAll({ 66 | limit: safeLimit, 67 | offset: skip, 68 | order: [["createdAt", "DESC"], ["authorId", "ASC"]] 69 | })) as Post[]; 70 | const edges = posts.map(post => post2Edge(post)); 71 | const firstEdge = edges[0]; 72 | const lastEdge = edges[edges.length - 1]; 73 | const pageInfo = getPageInfo( 74 | firstEdge, 75 | lastEdge, 76 | startOffset, 77 | endCursorOffset, 78 | totalCount 79 | ); 80 | const result = { 81 | edges, 82 | count: edges.length, 83 | totalCount, 84 | endCursorOffset, 85 | startCursorOffset: startOffset, 86 | pageInfo 87 | }; 88 | return result; 89 | }; 90 | -------------------------------------------------------------------------------- /src/modules/post/resolvers/post.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize"; 3 | import { Post } from "../../../models/Post.model"; 4 | import { graphqIdToId } from "../../../util/graphqlId"; 5 | import { post2IPost } from "../types/typeMap"; 6 | 7 | export const post: Resolver = async ( 8 | _, 9 | { id: graphId }: GQL.IPostOnQueryArguments, 10 | { sequelize } 11 | ) => { 12 | const PostModel = sequelize.models.Post as ModelCtor; 13 | const id = graphqIdToId(graphId, "post"); 14 | const postA = (await PostModel.findByPk(id)) as Post; 15 | if (!postA) { 16 | return null; 17 | } 18 | return post2IPost(postA); 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/post/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize"; 3 | import { User } from "../../../models/User.model"; 4 | import { user2IUser } from "../../user/types/typeMap"; 5 | 6 | export const PostUser: Resolver = async ( 7 | parent, 8 | _, 9 | { sequelize } 10 | ): Promise> => { 11 | const UserModel = sequelize.models.User as ModelCtor; 12 | const user = (await UserModel.findByPk(parent.authorId)) as User; 13 | return user2IUser(user); 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/post/resolvers/userCreatePost.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { applyMiddleware } from "../../../util/applyMiddleware"; 3 | import { authGraphqlMiddleware } from "../../middleware/auth"; 4 | import { ModelCtor } from "sequelize"; 5 | import { Post } from "../../../models/Post.model"; 6 | import { post2IPost } from "../types/typeMap"; 7 | 8 | export const UserCreatePost: Resolver = applyMiddleware( 9 | authGraphqlMiddleware, 10 | async ( 11 | _, 12 | { 13 | input: { description, clientMutationId, title } 14 | }: GQL.IUserCreatePostOnMutationArguments, 15 | { sequelize, userId } 16 | ) => { 17 | const PostModel = sequelize.models.Post as ModelCtor; 18 | const post = (await PostModel.create({ 19 | description, 20 | title, 21 | authorId: userId 22 | })) as Post; 23 | return { 24 | error: null, 25 | post: post2IPost(post), 26 | clientMutationId: clientMutationId || null 27 | }; 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /src/modules/post/schema.graphql: -------------------------------------------------------------------------------- 1 | type Post implements Node { 2 | id: ID! 3 | _id: String! 4 | title: String! 5 | description: String 6 | authorId: String! 7 | author: User! 8 | comments( 9 | first: Int 10 | after: String 11 | last: Int 12 | before: String 13 | ): PostCommentConnection 14 | } 15 | 16 | type Mutation { 17 | UserCreatePost(input: UserCreatePostInput!): UserCreatePostPayload 18 | EditPost(input: EditPostInput!): EditPostPayload! 19 | } 20 | 21 | type Query { 22 | post(id: ID!): Post 23 | feed(first: Int, after: String, last: Int, before: String): PostConnection 24 | } 25 | 26 | input UserCreatePostInput { 27 | title: String! 28 | description: String 29 | clientMutationId: String 30 | } 31 | 32 | type UserCreatePostPayload { 33 | error: String 34 | post: Post 35 | clientMutationId: String 36 | } 37 | 38 | type PostConnection { 39 | count: Int! 40 | """ 41 | A count of the total number of objects in this connection, ignoring pagination. 42 | This allows a client to fetch the first five objects by passing "5" as the 43 | argument to "first", then fetch the total count so it could display "5 of 83", 44 | for example. 45 | """ 46 | totalCount: Int! 47 | 48 | """ 49 | Offset from start 50 | """ 51 | startCursorOffset: Int! 52 | 53 | """ 54 | Offset till end 55 | """ 56 | endCursorOffset: Int! 57 | 58 | """ 59 | Information to aid in pagination. 60 | """ 61 | pageInfo: PageInfoExtended! 62 | 63 | """ 64 | A list of edges. 65 | """ 66 | edges: [PostEdge]! 67 | } 68 | 69 | """ 70 | An edge in a connection. 71 | """ 72 | type PostEdge { 73 | """ 74 | The item at the end of the edge 75 | """ 76 | node: Post! 77 | 78 | """ 79 | A cursor for use in pagination 80 | """ 81 | cursor: String! 82 | } 83 | 84 | input EditPostInput { 85 | title: String! 86 | id: String! 87 | clientMutationId: String 88 | } 89 | 90 | type EditPostPayload { 91 | post: Post 92 | error: String 93 | clientMutationId: String 94 | } 95 | -------------------------------------------------------------------------------- /src/modules/post/types/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const ForbiddenPostEdit = "you have no permissions to edit this post"; 2 | -------------------------------------------------------------------------------- /src/modules/post/types/typeMap.ts: -------------------------------------------------------------------------------- 1 | import { cursor2String, string2Cursor } from "../../../util/typeMap"; 2 | import { Post } from "../../../models/Post.model"; 3 | import { idToGraphqlId } from "../../../util/graphqlId"; 4 | import { model2Node, Node2ModelResolver } from "../../../types/graphql-utils"; 5 | 6 | export const idPrefix = "post"; 7 | export const cursorPrefix = "post-authorId-createdAt-"; 8 | export const modelName = "Post"; 9 | export const resolveType = "Post"; 10 | 11 | export const post2IPost: model2Node = ( 12 | post: Post 13 | ): Partial => ({ 14 | _id: post._id, 15 | id: idToGraphqlId(post._id, idPrefix), 16 | title: post.title, 17 | description: post.description, 18 | authorId: post.authorId 19 | }); 20 | 21 | export const post2Edge = (post: Post) => ({ 22 | node: post2IPost(post), 23 | cursor: post2Cursor(post) 24 | }); 25 | 26 | export const post2Cursor = (post: Post) => 27 | string2Cursor( 28 | `${post.authorId}||${(post.createdAt as Date).getTime()}`, 29 | cursorPrefix 30 | ); 31 | 32 | export const cursor2Post = (cursor: string) => { 33 | const data = cursor2String(cursor, cursorPrefix).split("||"); 34 | return { 35 | createdAt: new Date(Number.parseInt(data[1], 10)), 36 | authorId: data[0] 37 | }; 38 | }; 39 | 40 | export const PostNode2ModelResolver: Node2ModelResolver = { 41 | idPrefix, 42 | model2Interface: post2IPost, 43 | modelName, 44 | resolveType 45 | }; 46 | -------------------------------------------------------------------------------- /src/modules/schema.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | UserChangePassword(input: UserChangePasswordInput!): UserChangePasswordPayload 3 | UserLoginWithEmail(input: UserLoginWithEmailInput!): UserLoginWithEmailPayload 4 | UserRegisterWithEmail(input: UserRegisterWithEmailInput!): UserRegisterWithEmailPayload 5 | } 6 | 7 | """An object with an ID""" 8 | interface Node { 9 | """The id of the object.""" 10 | id: ID! 11 | } 12 | 13 | """Information about pagination in a connection.""" 14 | type PageInfoExtended { 15 | """When paginating forwards, are there more items?""" 16 | hasNextPage: Boolean! 17 | 18 | """When paginating backwards, are there more items?""" 19 | hasPreviousPage: Boolean! 20 | 21 | """When paginating backwards, the cursor to continue.""" 22 | startCursor: String 23 | 24 | """When paginating forwards, the cursor to continue.""" 25 | endCursor: String 26 | } 27 | 28 | """The root of all... queries""" 29 | type Query { 30 | """Fetches an object given its ID""" 31 | node( 32 | """The ID of an object""" 33 | id: ID! 34 | ): Node 35 | me: User 36 | user(id: ID!): User 37 | users(after: String, first: Int, before: String, last: Int, search: String): UserConnection 38 | } 39 | 40 | type Subscription { 41 | UserAdded: UserAddedPayload 42 | } 43 | 44 | """User data""" 45 | type User implements Node { 46 | """The ID of an object""" 47 | id: ID! 48 | _id: String 49 | name: String 50 | email: String 51 | active: Boolean 52 | } 53 | 54 | type UserAddedPayload { 55 | userEdge: UserEdge 56 | } 57 | 58 | input UserChangePasswordInput { 59 | oldPassword: String! 60 | 61 | """user new password""" 62 | password: String! 63 | clientMutationId: String 64 | } 65 | 66 | type UserChangePasswordPayload { 67 | error: String 68 | me: User 69 | clientMutationId: String 70 | } 71 | 72 | """A connection to a list of items.""" 73 | type UserConnection { 74 | """Number of items in this connection""" 75 | count: Int! 76 | 77 | """ 78 | A count of the total number of objects in this connection, ignoring pagination. 79 | This allows a client to fetch the first five objects by passing "5" as the 80 | argument to "first", then fetch the total count so it could display "5 of 83", 81 | for example. 82 | """ 83 | totalCount: Int! 84 | 85 | """Offset from start""" 86 | startCursorOffset: Int! 87 | 88 | """Offset till end""" 89 | endCursorOffset: Int! 90 | 91 | """Information to aid in pagination.""" 92 | pageInfo: PageInfoExtended! 93 | 94 | """A list of edges.""" 95 | edges: [UserEdge]! 96 | } 97 | 98 | """An edge in a connection.""" 99 | type UserEdge { 100 | """The item at the end of the edge""" 101 | node: User! 102 | 103 | """A cursor for use in pagination""" 104 | cursor: String! 105 | } 106 | 107 | input UserLoginWithEmailInput { 108 | email: String! 109 | password: String! 110 | clientMutationId: String 111 | } 112 | 113 | type UserLoginWithEmailPayload { 114 | token: String 115 | error: String 116 | clientMutationId: String 117 | } 118 | 119 | input UserRegisterWithEmailInput { 120 | name: String! 121 | email: String! 122 | password: String! 123 | clientMutationId: String 124 | } 125 | 126 | type UserRegisterWithEmailPayload { 127 | token: String 128 | error: String 129 | clientMutationId: String 130 | } 131 | -------------------------------------------------------------------------------- /src/modules/shared/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../types/graphql-utils"; 2 | import { AvailableNode2ModelResolvers, AvailableModel } from "./types/typeMaps"; 3 | 4 | export const resolvers: ResolverMap = { 5 | Query: { 6 | node: async ( 7 | _: any, 8 | { id }: GQL.INodeOnQueryArguments, 9 | { sequelize } 10 | ): Promise => { 11 | const sections = id.split("-"); 12 | const idValue = sections.slice(sections.length - 5).join("-"); 13 | const prefix = sections.slice(0, sections.length - 5).join("-"); 14 | const correctResolver = AvailableNode2ModelResolvers.find( 15 | resolver => resolver.idPrefix === prefix 16 | ); 17 | if (!correctResolver) { 18 | throw Error("type not found"); 19 | } 20 | const Model = sequelize.models[correctResolver.modelName]; 21 | const item = (await Model.findByPk(idValue)) as AvailableModel; 22 | if (!item) { 23 | return null; 24 | } 25 | const node = correctResolver.model2Interface(item); 26 | return { 27 | ...node, 28 | resolveType: correctResolver.resolveType 29 | }; 30 | } 31 | }, 32 | Node: { 33 | __resolveType: obj => { 34 | return obj.resolveType; 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/modules/shared/types/typeMaps.ts: -------------------------------------------------------------------------------- 1 | import { UserNode2ModelResolver } from "../../user/types/typeMap"; 2 | import { PostNode2ModelResolver } from "../../post/types/typeMap"; 3 | import { Node2ModelResolver } from "../../../types/graphql-utils"; 4 | import { Post } from "../../../models/Post.model"; 5 | import { User } from "../../../models/User.model"; 6 | import { Comment } from "../../../models/Comment.model"; 7 | import { CommentNode2ModelResolver } from "../../comment/types/typeMap"; 8 | 9 | export type AvailableModel = Post & User & Comment; 10 | 11 | export type AvailableNode2ModelResolver = 12 | | Node2ModelResolver 13 | | Node2ModelResolver 14 | | Node2ModelResolver; 15 | 16 | export const AvailableNode2ModelResolvers: AvailableNode2ModelResolver[] = [ 17 | UserNode2ModelResolver, 18 | PostNode2ModelResolver, 19 | CommentNode2ModelResolver 20 | ]; 21 | -------------------------------------------------------------------------------- /src/modules/user/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { default as UserChangePassword } from "./resolvers/changePassword"; 2 | import { default as UserLoginWithEmail } from "./resolvers/login"; 3 | import { default as UserRegisterWithEmail } from "./resolvers/register"; 4 | import { userResolver as user, meResolver as me } from "./resolvers/queries"; 5 | import { usersResolver as users } from "./resolvers/users"; 6 | import { ResolverMap } from "../../types/graphql-utils"; 7 | import { userAddedResolver } from "./resolvers/userAdded"; 8 | 9 | export const resolvers: ResolverMap = { 10 | Mutation: { 11 | UserChangePassword, 12 | UserLoginWithEmail, 13 | UserRegisterWithEmail 14 | }, 15 | Query: { 16 | user, 17 | users, 18 | me 19 | }, 20 | Subscription: { 21 | UserAdded: { 22 | subscribe: userAddedResolver 23 | } 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/changePassword.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import * as bcrypt from "bcryptjs"; 3 | import { User } from "../../../models/User.model"; 4 | import { invalidAuthentication } from "../../middleware/errorMessages"; 5 | import { user2IUser } from "../../../modules/user/types/typeMap"; 6 | import { applyMiddleware } from "../../../util/applyMiddleware"; 7 | import { authGraphqlMiddleware } from "../../middleware/auth"; 8 | 9 | const changePasswordResolver: Resolver = async ( 10 | _, 11 | { 12 | input: { clientMutationId, oldPassword, password } 13 | }: GQL.IUserChangePasswordOnMutationArguments, 14 | { sequelize, userId } 15 | ) => { 16 | const UserModel = sequelize.models.User; 17 | const user = (await UserModel.findByPk(userId)) as User; 18 | if (!user) { 19 | return { 20 | clientMutationId: clientMutationId || null, 21 | me: null, 22 | error: invalidAuthentication 23 | }; 24 | } 25 | const valid = await bcrypt.compare(oldPassword, user.password); 26 | if (!valid) { 27 | return { 28 | clientMutationId: clientMutationId || null, 29 | me: null, 30 | error: invalidAuthentication 31 | }; 32 | } 33 | const newPassword = await bcrypt.hash(password, 10); 34 | await user.update({ password: newPassword }); 35 | return { 36 | me: user2IUser(user), 37 | error: null, 38 | clientMutationId: clientMutationId || null 39 | }; 40 | }; 41 | 42 | export default applyMiddleware(authGraphqlMiddleware, changePasswordResolver); 43 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/login.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { invalidLogin } from "../types/errorMessages"; 3 | import * as bcrypt from "bcryptjs"; 4 | import { User } from "../../../models/User.model"; 5 | import * as jwt from "jsonwebtoken"; 6 | import * as tp from "typed-promisify"; 7 | 8 | const sign = tp.promisify(jwt.sign); 9 | 10 | const loginResolver: Resolver = async ( 11 | _, 12 | { 13 | input: { clientMutationId, email, password } 14 | }: GQL.IUserLoginWithEmailOnMutationArguments, 15 | { sequelize } 16 | ): Promise => { 17 | const UserModel = sequelize.models.User; 18 | const user = (await UserModel.findOne({ where: { email } })) as User; 19 | if (!user) { 20 | return { 21 | token: null, 22 | error: invalidLogin, 23 | clientMutationId: clientMutationId || null 24 | }; 25 | } 26 | const valid = await bcrypt.compare(password, user.password); 27 | if (valid) { 28 | const token = (await sign( 29 | { userId: user._id }, 30 | process.env.APP_SECRET || "secret" 31 | )) as string; 32 | return { 33 | token, 34 | error: null, 35 | clientMutationId: clientMutationId || null 36 | }; 37 | } 38 | return { 39 | token: null, 40 | error: invalidLogin, 41 | clientMutationId: clientMutationId || null 42 | }; 43 | }; 44 | 45 | export default loginResolver; 46 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/queries.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { applyMiddleware } from "../../../util/applyMiddleware"; 3 | import { authGraphqlMiddleware } from "../../middleware/auth"; 4 | import { User } from "../../../models/User.model"; 5 | import { user2IUser } from "../../../modules/user/types/typeMap"; 6 | import { graphqIdToId } from "../../../util/graphqlId"; 7 | 8 | export const userResolver: Resolver = async ( 9 | _, 10 | { id: graphId }: GQL.IUserOnQueryArguments, 11 | { sequelize } 12 | ) => { 13 | const id = graphqIdToId(graphId, "user"); 14 | const user = (await sequelize.models.User.findByPk(id)) as User; 15 | if (!user) { 16 | return null; 17 | } 18 | return user2IUser(user); 19 | }; 20 | 21 | export const meResolver: Resolver = applyMiddleware( 22 | authGraphqlMiddleware, 23 | async (_, __, { userId, sequelize }) => { 24 | if (!userId) { 25 | return null; 26 | } 27 | const UserModel = sequelize.models.User; 28 | const me = (await UserModel.findByPk(userId)) as User; 29 | return user2IUser(me); 30 | }, 31 | { throwError: false } 32 | ); 33 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/register.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from "jsonwebtoken"; 2 | import { Resolver } from "../../../types/graphql-utils"; 3 | import { User } from "../../../models/User.model"; 4 | import * as tp from "typed-promisify"; 5 | import { userAddedChannelName } from "../../../util/constants"; 6 | import { user2Edge } from "../types/typeMap"; 7 | 8 | const sign = tp.promisify(jwt.sign); 9 | 10 | const registerResolver: Resolver = async ( 11 | _, 12 | { 13 | input: { email, name, password, clientMutationId } 14 | }: GQL.IUserRegisterWithEmailOnMutationArguments, 15 | { sequelize, pubsub } 16 | ) => { 17 | const UserModel = sequelize.models.User; 18 | const user = (await UserModel.create({ 19 | email, 20 | password, 21 | name 22 | })) as User; 23 | const token = (await sign( 24 | { userId: user._id }, 25 | process.env.APP_SECRET || "secret" 26 | )) as string; 27 | const userAdded = { 28 | userEdge: user2Edge(user) 29 | }; 30 | pubsub.publish(userAddedChannelName, { UserAdded: userAdded }); 31 | return { 32 | token, 33 | error: null, 34 | clientMutationId: clientMutationId as string | null 35 | }; 36 | }; 37 | 38 | export default registerResolver; 39 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/userAdded.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { userAddedChannelName } from "../../../util/constants"; 3 | 4 | export const userAddedResolver: Resolver = (_, __, { pubsub }) => { 5 | return pubsub.asyncIterator(userAddedChannelName); 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/user/resolvers/users.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | import { ModelCtor } from "sequelize-typescript"; 3 | import { User } from "../../../models/User.model"; 4 | import { Op } from "sequelize"; 5 | import { cursor2UserName, user2Edge } from "../types/typeMap"; 6 | import { 7 | parseFirstLast, 8 | calculateOffstetLimit, 9 | getPageInfo 10 | } from "../../../util/connectionUtils"; 11 | 12 | export const cursor2Offset = ( 13 | cursor: string | null | undefined, 14 | defaultValue: number, 15 | search: any, 16 | UserModel: ModelCtor 17 | ) => { 18 | if (!cursor) { 19 | return defaultValue; 20 | } 21 | const nameAfter = cursor2UserName(cursor); 22 | return UserModel.count({ 23 | where: { 24 | name: { 25 | [Op.lt]: nameAfter 26 | }, 27 | ...search 28 | } 29 | }); 30 | }; 31 | 32 | export const usersResolver: Resolver = async ( 33 | _, 34 | args: GQL.IUsersOnQueryArguments, 35 | { sequelize } 36 | ) => { 37 | const UserModel = sequelize.models.User as ModelCtor; 38 | const { before, after, search } = args; 39 | const { first, last } = parseFirstLast(args.first, args.last); 40 | const where = search 41 | ? { 42 | [Op.or]: [ 43 | { 44 | name: { 45 | [Op.iLike]: `%${search}%` 46 | } 47 | }, 48 | { 49 | email: { 50 | [Op.iLike]: `%${search}%` 51 | } 52 | } 53 | ] 54 | } 55 | : {}; 56 | const total = await UserModel.count({ where }); 57 | const afterOffset = await cursor2Offset(after, -1, where, UserModel); 58 | const beforeOffset = await cursor2Offset(before, total, where, UserModel); 59 | const { 60 | endCursorOffset, 61 | limitOffset, 62 | safeLimit, 63 | startOffset, 64 | skip 65 | } = calculateOffstetLimit(first, last, total, afterOffset, beforeOffset); 66 | const users = (await UserModel.findAll({ 67 | limit: safeLimit, 68 | offset: skip, 69 | where, 70 | order: [["name", "ASC"]] 71 | })) as User[]; 72 | const edges = users.map(user => user2Edge(user)); 73 | const firstEdge = edges[0]; 74 | const lastEdge = edges[edges.length - 1]; 75 | const pageInfo = getPageInfo( 76 | firstEdge, 77 | lastEdge, 78 | startOffset, 79 | endCursorOffset, 80 | total 81 | ); 82 | return { 83 | edges, 84 | count: edges.length, 85 | totalCount: total, 86 | endCursorOffset: limitOffset + skip, 87 | startCursorOffset: startOffset, 88 | pageInfo 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/modules/user/types/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const invalidLogin = "invalid login"; 2 | -------------------------------------------------------------------------------- /src/modules/user/types/typeMap.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../../models/User.model"; 2 | import { idToGraphqlId } from "../../../util/graphqlId"; 3 | import { string2Cursor, cursor2String } from "../../../util/typeMap"; 4 | import { Node2ModelResolver, model2Node } from "../../../types/graphql-utils"; 5 | 6 | export const idPrefix = "user"; 7 | export const cursorPrefix = "user-name-"; 8 | export const modelName = "User"; 9 | export const resolveType = "User"; 10 | 11 | export const user2IUser: model2Node = ( 12 | user: User 13 | ): GQL.IUser => ({ 14 | active: user.active, 15 | name: user.name, 16 | email: user.email, 17 | _id: user._id, 18 | id: idToGraphqlId(user._id, idPrefix) 19 | }); 20 | 21 | export const user2Edge = (user: User) => ({ 22 | node: user2IUser(user), 23 | cursor: string2Cursor(user.name, cursorPrefix) 24 | }); 25 | 26 | export const user2Cursor = (user: User) => 27 | string2Cursor(user.name, cursorPrefix); 28 | 29 | export const cursor2UserName = (cursor: string) => 30 | cursor2String(cursor, cursorPrefix); 31 | 32 | export const UserNode2ModelResolver: Node2ModelResolver = { 33 | idPrefix, 34 | model2Interface: user2IUser, 35 | modelName, 36 | resolveType 37 | }; 38 | -------------------------------------------------------------------------------- /src/scripts/genSchemaFile.ts: -------------------------------------------------------------------------------- 1 | import { mergeSchemas } from "../modules/mergeSchemas"; 2 | import * as path from "path"; 3 | import * as fs from "fs"; 4 | 5 | const pathToModules = path.join(__dirname, "..", "modules"); 6 | const file = mergeSchemas(pathToModules); 7 | fs.writeFile( 8 | path.join(__dirname, "..", "..", "mainSchema.graphql"), 9 | file, 10 | err => console.log(err) 11 | ); 12 | -------------------------------------------------------------------------------- /src/scripts/genSchemaTypes.ts: -------------------------------------------------------------------------------- 1 | import { generateNamespace } from "@gql2ts/from-schema"; 2 | import { genSchema } from "../modules"; 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | import "dotenv/config"; 6 | 7 | const myNamespace = generateNamespace("GQL", genSchema(), { 8 | ignoreTypeNameDeclaration: true 9 | }); 10 | fs.writeFile( 11 | path.join(__dirname, "..", "types", "schemaTypes.d.ts"), 12 | myNamespace, 13 | e => { 14 | console.log(e); 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /src/scripts/populateDB.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from "../services/sequelize"; 2 | import * as faker from "faker"; 3 | import { range } from "lodash"; 4 | import { ModelCtor } from "sequelize/types"; 5 | import { User } from "../models/User.model"; 6 | import { Post } from "../models/Post.model"; 7 | 8 | const UserModel = sequelize.models.User as ModelCtor; 9 | const PostModel = sequelize.models.Post as ModelCtor; 10 | 11 | const populateDB = async () => { 12 | const userRange = range(0, 20); 13 | const createPromises = userRange.map(() => 14 | UserModel.create({ 15 | name: faker.internet.userName(), 16 | password: faker.internet.password(), 17 | email: faker.internet.email() 18 | }) 19 | ); 20 | const users = (await Promise.all(createPromises)) as User[]; 21 | const postPromises = users.map(user => 22 | PostModel.create({ 23 | description: faker.lorem.paragraph(), 24 | title: faker.name.title(), 25 | authorId: user._id 26 | }) 27 | ); 28 | return Promise.all(postPromises); 29 | }; 30 | 31 | populateDB() 32 | .then(() => console.log("database populated")) 33 | .catch(err => console.log(err)); 34 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLServer } from "graphql-yoga"; 2 | import { genSchema } from "./modules"; 3 | import { sequelize } from "./services/sequelize"; 4 | import { pubsub } from "./services/pubsub"; 5 | class Server extends GraphQLServer { 6 | constructor() { 7 | const schema = genSchema(); 8 | super({ 9 | schema, 10 | context: ({ request }) => ({ 11 | sequelize, 12 | request, 13 | pubsub 14 | }) 15 | }); 16 | this.middleware(); 17 | this.routes(); 18 | } 19 | 20 | middleware() { 21 | // middlewares 22 | } 23 | 24 | routes() { 25 | // routes 26 | } 27 | } 28 | 29 | export default new Server(); 30 | -------------------------------------------------------------------------------- /src/services/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from "graphql-yoga"; 2 | 3 | export const pubsub = new PubSub(); 4 | -------------------------------------------------------------------------------- /src/services/sequelize.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from "sequelize-typescript"; 2 | import { default as Config } from "../config/sequelize"; 3 | 4 | const sequelize = new Sequelize({ 5 | ...Config, 6 | modelMatch: (filename, member) => { 7 | return filename.substring(0, filename.indexOf(".model")) === member; 8 | }, 9 | models: [__dirname + "/../models/**/*.model.ts"] 10 | }); 11 | 12 | export { sequelize }; 13 | -------------------------------------------------------------------------------- /src/types/graphql-utils.d.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import { Sequelize } from "sequelize-typescript"; 3 | import { PubSub } from "graphql-yoga"; 4 | import { Model } from "sequelize"; 5 | 6 | export interface Context { 7 | sequelize: Sequelize; 8 | request: Request; 9 | userId: string | undefined; 10 | pubsub: PubSub; 11 | } 12 | 13 | export type Resolver = ( 14 | parent: any, 15 | agrs: any, 16 | context: Context, 17 | info: any 18 | ) => any; 19 | 20 | export interface SubscriptionResolver { 21 | subscribe: (parent: any, agrs: any, context: Context, info: any) => any; 22 | } 23 | 24 | export type GraphqlMiddleware = ( 25 | resolver: Resolver, 26 | parent: any, 27 | args: any, 28 | context: Context, 29 | info: any, 30 | params: any | undefined 31 | ) => any; 32 | 33 | export interface ResolverMap { 34 | [key: string]: { 35 | [key: string]: Resolver | SubscriptionResolver; 36 | }; 37 | } 38 | 39 | export type model2Node = ( 40 | model: T 41 | ) => Partial; 42 | 43 | export interface Node2ModelResolver { 44 | idPrefix: string; 45 | model2Interface: model2Node; 46 | modelName: string; 47 | resolveType: string; 48 | } 49 | -------------------------------------------------------------------------------- /src/types/merge-graphql-schemas.d.ts: -------------------------------------------------------------------------------- 1 | declare module "merge-graphql-schemas"; 2 | -------------------------------------------------------------------------------- /src/types/request.d.ts: -------------------------------------------------------------------------------- 1 | export interface VerifiedRequest extends Express.Request { 2 | userId: string | undefined; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/schemaTypes.d.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable 2 | // graphql typescript definitions 3 | 4 | declare namespace GQL { 5 | interface IGraphQLResponseRoot { 6 | data?: IQuery | IMutation | ISubscription; 7 | errors?: Array; 8 | } 9 | 10 | interface IGraphQLResponseError { 11 | /** Required for all errors */ 12 | message: string; 13 | locations?: Array; 14 | /** 7.2.2 says 'GraphQL servers may provide additional entries to error' */ 15 | [propName: string]: any; 16 | } 17 | 18 | interface IGraphQLResponseErrorLocation { 19 | line: number; 20 | column: number; 21 | } 22 | 23 | /** 24 | * The root of all... queries 25 | */ 26 | interface IQuery { 27 | randomUser: IUser; 28 | post: IPost | null; 29 | feed: IPostConnection | null; 30 | 31 | /** 32 | * Fetches an object given its ID 33 | */ 34 | node: Node | null; 35 | me: IUser | null; 36 | user: IUser | null; 37 | users: IUserConnection | null; 38 | } 39 | 40 | interface IPostOnQueryArguments { 41 | id: string; 42 | } 43 | 44 | interface IFeedOnQueryArguments { 45 | first?: number | null; 46 | after?: string | null; 47 | last?: number | null; 48 | before?: string | null; 49 | } 50 | 51 | interface INodeOnQueryArguments { 52 | 53 | /** 54 | * The ID of an object 55 | */ 56 | id: string; 57 | } 58 | 59 | interface IUserOnQueryArguments { 60 | id: string; 61 | } 62 | 63 | interface IUsersOnQueryArguments { 64 | after?: string | null; 65 | first?: number | null; 66 | before?: string | null; 67 | last?: number | null; 68 | search?: string | null; 69 | } 70 | 71 | /** 72 | * User data 73 | */ 74 | interface IUser { 75 | 76 | /** 77 | * The ID of an object 78 | */ 79 | id: string; 80 | _id: string | null; 81 | name: string | null; 82 | email: string | null; 83 | active: boolean | null; 84 | } 85 | 86 | /** 87 | * An object with an ID 88 | */ 89 | type Node = IUser | IPost | IComment; 90 | 91 | /** 92 | * An object with an ID 93 | */ 94 | interface INode { 95 | 96 | /** 97 | * The id of the object. 98 | */ 99 | id: string; 100 | } 101 | 102 | interface IPost { 103 | id: string; 104 | _id: string; 105 | title: string; 106 | description: string | null; 107 | authorId: string; 108 | author: IUser; 109 | comments: IPostCommentConnection | null; 110 | } 111 | 112 | interface ICommentsOnPostArguments { 113 | first?: number | null; 114 | after?: string | null; 115 | last?: number | null; 116 | before?: string | null; 117 | } 118 | 119 | interface IPostCommentConnection { 120 | count: number; 121 | totalCount: number; 122 | startCursorOffset: number; 123 | endCursorOffset: number; 124 | pageInfo: IPageInfoExtended; 125 | edges: Array; 126 | } 127 | 128 | /** 129 | * Information about pagination in a connection. 130 | */ 131 | interface IPageInfoExtended { 132 | 133 | /** 134 | * When paginating forwards, are there more items? 135 | */ 136 | hasNextPage: boolean; 137 | 138 | /** 139 | * When paginating backwards, are there more items? 140 | */ 141 | hasPreviousPage: boolean; 142 | 143 | /** 144 | * When paginating backwards, the cursor to continue. 145 | */ 146 | startCursor: string | null; 147 | 148 | /** 149 | * When paginating forwards, the cursor to continue. 150 | */ 151 | endCursor: string | null; 152 | } 153 | 154 | interface IPostCommentEdge { 155 | node: IComment | null; 156 | cursor: string | null; 157 | } 158 | 159 | interface IComment { 160 | id: string; 161 | _id: string; 162 | authorId: string; 163 | postId: string | null; 164 | comment: string; 165 | author: IUser; 166 | post: IPost; 167 | } 168 | 169 | interface IPostConnection { 170 | count: number; 171 | 172 | /** 173 | * A count of the total number of objects in this connection, ignoring pagination. 174 | * This allows a client to fetch the first five objects by passing "5" as the 175 | * argument to "first", then fetch the total count so it could display "5 of 83", 176 | * for example. 177 | */ 178 | totalCount: number; 179 | 180 | /** 181 | * Offset from start 182 | */ 183 | startCursorOffset: number; 184 | 185 | /** 186 | * Offset till end 187 | */ 188 | endCursorOffset: number; 189 | 190 | /** 191 | * Information to aid in pagination. 192 | */ 193 | pageInfo: IPageInfoExtended; 194 | 195 | /** 196 | * A list of edges. 197 | */ 198 | edges: Array; 199 | } 200 | 201 | /** 202 | * An edge in a connection. 203 | */ 204 | interface IPostEdge { 205 | 206 | /** 207 | * The item at the end of the edge 208 | */ 209 | node: IPost; 210 | 211 | /** 212 | * A cursor for use in pagination 213 | */ 214 | cursor: string; 215 | } 216 | 217 | /** 218 | * A connection to a list of items. 219 | */ 220 | interface IUserConnection { 221 | 222 | /** 223 | * Number of items in this connection 224 | */ 225 | count: number; 226 | 227 | /** 228 | * A count of the total number of objects in this connection, ignoring pagination. 229 | * This allows a client to fetch the first five objects by passing "5" as the 230 | * argument to "first", then fetch the total count so it could display "5 of 83", 231 | * for example. 232 | */ 233 | totalCount: number; 234 | 235 | /** 236 | * Offset from start 237 | */ 238 | startCursorOffset: number; 239 | 240 | /** 241 | * Offset till end 242 | */ 243 | endCursorOffset: number; 244 | 245 | /** 246 | * Information to aid in pagination. 247 | */ 248 | pageInfo: IPageInfoExtended; 249 | 250 | /** 251 | * A list of edges. 252 | */ 253 | edges: Array; 254 | } 255 | 256 | /** 257 | * An edge in a connection. 258 | */ 259 | interface IUserEdge { 260 | 261 | /** 262 | * The item at the end of the edge 263 | */ 264 | node: IUser; 265 | 266 | /** 267 | * A cursor for use in pagination 268 | */ 269 | cursor: string; 270 | } 271 | 272 | interface IMutation { 273 | CreateComment: ICreateCommentPayload | null; 274 | UserCreatePost: IUserCreatePostPayload | null; 275 | EditPost: IEditPostPayload; 276 | UserChangePassword: IUserChangePasswordPayload | null; 277 | UserLoginWithEmail: IUserLoginWithEmailPayload | null; 278 | UserRegisterWithEmail: IUserRegisterWithEmailPayload | null; 279 | } 280 | 281 | interface ICreateCommentOnMutationArguments { 282 | input: ICreateCommentInput; 283 | } 284 | 285 | interface IUserCreatePostOnMutationArguments { 286 | input: IUserCreatePostInput; 287 | } 288 | 289 | interface IEditPostOnMutationArguments { 290 | input: IEditPostInput; 291 | } 292 | 293 | interface IUserChangePasswordOnMutationArguments { 294 | input: IUserChangePasswordInput; 295 | } 296 | 297 | interface IUserLoginWithEmailOnMutationArguments { 298 | input: IUserLoginWithEmailInput; 299 | } 300 | 301 | interface IUserRegisterWithEmailOnMutationArguments { 302 | input: IUserRegisterWithEmailInput; 303 | } 304 | 305 | interface ICreateCommentInput { 306 | postId: string; 307 | comment: string; 308 | clientMutationId?: string | null; 309 | } 310 | 311 | interface ICreateCommentPayload { 312 | comment: IComment | null; 313 | clientMutationId: string | null; 314 | error: string | null; 315 | } 316 | 317 | interface IUserCreatePostInput { 318 | title: string; 319 | description?: string | null; 320 | clientMutationId?: string | null; 321 | } 322 | 323 | interface IUserCreatePostPayload { 324 | error: string | null; 325 | post: IPost | null; 326 | clientMutationId: string | null; 327 | } 328 | 329 | interface IEditPostInput { 330 | title: string; 331 | id: string; 332 | clientMutationId?: string | null; 333 | } 334 | 335 | interface IEditPostPayload { 336 | post: IPost | null; 337 | error: string | null; 338 | clientMutationId: string | null; 339 | } 340 | 341 | interface IUserChangePasswordInput { 342 | oldPassword: string; 343 | 344 | /** 345 | * user new password 346 | */ 347 | password: string; 348 | clientMutationId?: string | null; 349 | } 350 | 351 | interface IUserChangePasswordPayload { 352 | error: string | null; 353 | me: IUser | null; 354 | clientMutationId: string | null; 355 | } 356 | 357 | interface IUserLoginWithEmailInput { 358 | email: string; 359 | password: string; 360 | clientMutationId?: string | null; 361 | } 362 | 363 | interface IUserLoginWithEmailPayload { 364 | token: string | null; 365 | error: string | null; 366 | clientMutationId: string | null; 367 | } 368 | 369 | interface IUserRegisterWithEmailInput { 370 | name: string; 371 | email: string; 372 | password: string; 373 | clientMutationId?: string | null; 374 | } 375 | 376 | interface IUserRegisterWithEmailPayload { 377 | token: string | null; 378 | error: string | null; 379 | clientMutationId: string | null; 380 | } 381 | 382 | interface ISubscription { 383 | UserAdded: IUserAddedPayload | null; 384 | } 385 | 386 | interface IUserAddedPayload { 387 | userEdge: IUserEdge | null; 388 | } 389 | } 390 | 391 | // tslint:enable 392 | -------------------------------------------------------------------------------- /src/util/applyMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, GraphqlMiddleware } from "../types/graphql-utils"; 2 | 3 | export const applyMiddleware = ( 4 | middleware: GraphqlMiddleware, 5 | resolver: Resolver, 6 | params: any | undefined = undefined 7 | ) => (parent: any, args: any, context: any, info: any) => 8 | middleware(resolver, parent, args, context, info, params); 9 | -------------------------------------------------------------------------------- /src/util/connectionUtils.ts: -------------------------------------------------------------------------------- 1 | export const parseFirstLast = ( 2 | first: number | undefined | null, 3 | last: number | undefined | null 4 | ) => { 5 | if (!first && !last) { 6 | first = 10; 7 | } 8 | if (first && first > 1000) { 9 | first = 1000; 10 | } 11 | if (last && last > 1000) { 12 | last = 1000; 13 | } 14 | return { 15 | first, 16 | last 17 | }; 18 | }; 19 | 20 | export const calculateOffstetLimit = ( 21 | first: number | undefined | null, 22 | last: number | undefined | null, 23 | totalCount: number, 24 | afterOffset: number, 25 | beforeOffset: number 26 | ) => { 27 | let startOffset = Math.max(-1, afterOffset) + 1; 28 | let endOffset = Math.min(totalCount, beforeOffset); 29 | if (first !== undefined && first !== null) { 30 | endOffset = Math.min(endOffset, startOffset + first); 31 | } 32 | if (last !== undefined && last !== null) { 33 | startOffset = Math.max(startOffset, endOffset - (last || 0)); 34 | } 35 | const skip = Math.max(startOffset, 0); 36 | const safeLimit = Math.max(endOffset - startOffset, 1); 37 | const limitOffset = Math.max(endOffset - startOffset, 0); 38 | const endCursorOffset = limitOffset + skip; 39 | return { 40 | skip, 41 | safeLimit, 42 | limitOffset, 43 | startOffset, 44 | endOffset, 45 | endCursorOffset 46 | }; 47 | }; 48 | 49 | export const getPageInfo = ( 50 | firstEdge: { cursor: string } | null, 51 | lastEdge: { cursor: string } | null, 52 | startOffset: number, 53 | endCursorOffset: number, 54 | totalCount: number 55 | ): GQL.IPageInfoExtended => ({ 56 | startCursor: firstEdge ? firstEdge.cursor : null, 57 | endCursor: lastEdge ? lastEdge.cursor : null, 58 | hasPreviousPage: startOffset > 0, 59 | hasNextPage: endCursorOffset < totalCount 60 | }); 61 | -------------------------------------------------------------------------------- /src/util/constants.ts: -------------------------------------------------------------------------------- 1 | export const userAddedChannelName = "graphql:UserAddedChannel"; 2 | -------------------------------------------------------------------------------- /src/util/graphqlId.ts: -------------------------------------------------------------------------------- 1 | export const idToGraphqlId = (id: string, prefix: string) => { 2 | return `${prefix}-${id}`; 3 | }; 4 | 5 | export const graphqIdToId = (id: string, prefix: string) => { 6 | return id.substring(prefix.length + 1); 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/typeMap.ts: -------------------------------------------------------------------------------- 1 | export const base64 = (str: string): string => 2 | Buffer.from(str, "ascii").toString("base64"); 3 | export const unbase64 = (b64: string): string => 4 | Buffer.from(b64, "base64").toString("ascii"); 5 | 6 | export const string2Cursor = (name: string, prefix: string) => 7 | prefix + base64(name); 8 | 9 | export const cursor2String = (cursor: string, prefix: string) => 10 | unbase64(cursor.substring(prefix.length)); 11 | -------------------------------------------------------------------------------- /src/util/verifyJwt.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import * as jwt from "jsonwebtoken"; 3 | import * as tp from "typed-promisify"; 4 | 5 | const verify = tp.promisify(jwt.verify); 6 | 7 | export const verifyJwt = async (request: Request): Promise => { 8 | const tokenHeader = request.get("Authorization"); 9 | if (!tokenHeader) { 10 | return null; 11 | } 12 | const token = tokenHeader.substring(7); 13 | return verify(token, process.env.APP_SECRET || "secret") as any; 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "moduleResolution": "node", 9 | 10 | "removeComments": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": false, 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true 22 | }, 23 | "exclude": ["node_modules"], 24 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "member-access": false, 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": false, 10 | "interface-name": false, 11 | "no-submodule-imports": false, 12 | "no-implicit-dependencies": [true, "dev"] 13 | }, 14 | "rulesDirectory": [] 15 | } 16 | --------------------------------------------------------------------------------