├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .lintstagedrc ├── .prettierignore ├── Dockerfile.dev ├── README.md ├── docker-compose.yml ├── jest.config.js ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── src ├── constants.ts ├── entity │ └── User.ts ├── graphql-types │ ├── FieldError.ts │ ├── LoginResponse.ts │ ├── PaginatedUserResponse.ts │ ├── RegisterResponse.ts │ ├── UserResponse.ts │ ├── interfaces │ │ └── Response.ts │ └── shared │ │ └── PaginatedResponse.ts ├── index.ts ├── modules │ ├── admin │ │ └── Admin.ts │ ├── hello │ │ └── Hello.ts │ ├── middleware │ │ ├── isAuth.ts │ │ ├── logger.ts │ │ └── rateLimit.ts │ ├── shared │ │ ├── PasswordComplexity.ts │ │ └── PasswordMixin.ts │ └── user │ │ ├── ChangePassword.ts │ │ ├── ConfirmUser.ts │ │ ├── FileUpload.ts │ │ ├── ForgotPassword.ts │ │ ├── Login.ts │ │ ├── Logout.ts │ │ ├── Me.ts │ │ ├── Register.ts │ │ ├── changePassword │ │ └── ChangePasswordInput.ts │ │ ├── me │ │ └── Me.test.ts │ │ └── register │ │ ├── Register.test.ts │ │ ├── RegisterInput.ts │ │ └── isEmailAlreadyExist.ts ├── redis.ts ├── tasks │ └── db │ │ └── seed.ts ├── test-utils │ ├── gCall.ts │ ├── setup.ts │ └── testConn.ts ├── types │ ├── MyContext.ts │ ├── PasswordConstraint.ts │ ├── ResolverType.ts │ ├── Upload.ts │ └── uuid.d.ts └── utils │ ├── authChecker.ts │ ├── authToken.ts │ ├── createConfirmationUrl.ts │ ├── createSchema.ts │ ├── ormconfig.ts │ ├── passwordValidator.ts │ ├── sendEmail.ts │ └── sendRefreshToken.ts ├── tsconfig.eslint.json ├── tsconfig.jest.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.js 3 | coverage 4 | dist 5 | node_modules 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // How to setup: 2 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser 3 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin 4 | module.exports = { 5 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 6 | plugins: ['@typescript-eslint'], 7 | // NOTE: The order of extends are from left to right, whatever is on the last, that rule set will override some rules of previous rule sets 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 10 | 'prettier', 11 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 12 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 13 | ], 14 | env: { 15 | node: true, 16 | jest: true 17 | }, 18 | parserOptions: { 19 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 20 | sourceType: 'module', // Allows for the use of imports 21 | project: './tsconfig.eslint.json', // Well use this eslint tsconfig to lint tests files 22 | tsConfigRootDir: '.' 23 | }, 24 | settings: { 25 | 'import/parser': { 26 | '@typescript-eslint/parser': ['.ts'], 27 | }, 28 | }, 29 | rules: { 30 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 31 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 32 | 'no-unused-vars': 0, 33 | '@typescript-eslint/explicit-module-boundary-types': 'off', 34 | '@typescript-eslint/explicit-function-return-type': 0, 35 | '@typescript-eslint/explicit-member-accessibility': 0, 36 | // '@typescript-eslint/naming-convention': false, 37 | '@typescript-eslint/no-var-requires': 0, 38 | '@typescript-eslint/no-non-null-assertion': 0, 39 | '@typescript-eslint/no-unused-vars': [ 40 | 1, 41 | { 42 | args: 'all', 43 | ignoreRestSiblings: false, 44 | argsIgnorePattern: '^_|^root|^args|^parent|^ctx|^context|^info|^type|^target|^returns|^req|^res', 45 | }, 46 | ], 47 | "@typescript-eslint/naming-convention": [ 48 | "error", 49 | { "selector": "variableLike", "format": ["camelCase"] }, 50 | { "selector": "interface", "format": ["PascalCase"], prefix: ["I"] }, 51 | ], 52 | '@typescript-eslint/interface-name-prefix': [0, { "prefixWithI": "always" }], 53 | 'prettier/prettier': [ 54 | 'error', 55 | { 56 | useTabs: true, 57 | semi: false, 58 | tabWidth: 2, 59 | singleQuote: true, 60 | trailingComma: 'all', 61 | printWidth: 120, 62 | 'object-curly-spacing': ['error', 'always'], 63 | 'array-bracket-spacing': ['error', 'always'], 64 | 'computed-property-spacing': ['error', 'always'], 65 | }, 66 | ], 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ 7 | .env 8 | yarn.lock 9 | tsconfig.tsbuildinfo 10 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "linters": { 3 | "*.@(js|ts)": ["eslint", "jest --coverage --findRelatedTests"], 4 | "*.@(js|ts|graphql|gql|json|yml|yaml)": ["prettier --write", "git add"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | build 5 | .build 6 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL TS Server Boilerplate 2 | 3 | Steps to run this repo: 4 | 5 | 1. Run `yarn install` command 6 | 2. Setup database settings inside `ormconfig.ts` file 7 | 3. Setup .env 8 | 9 | ```env 10 | TYPEORM_HOST= 11 | TYPEORM_USERNAME= 12 | TYPEORM_PASSWORD= 13 | TYPEORM_DATABASE= 14 | DEBUG=true 15 | ACCESS_TOKEN_SECRET= 16 | REFRESH_TOKEN_SECRET= 17 | SESSION_SECRET= 18 | FRONTEND_URL=http://localhost:3000 19 | ``` 20 | 21 | 4. Run `yarn start` command 22 | 23 | ## Schema or Code First 24 | 25 | - Before using this Boilerplate it is recommended that you know Schema-First GraphQL Development 26 | - Type-GraphQL lies on Code First while Prisma on Schema-First 27 | 28 | ## Why Type-GraphQL for SDL Development 29 | 30 | 1. You can use a single class for both a db model and graphql model 31 | 2. Way smoother to code (generating types is clunky) 32 | 3. The schema + resolver can be together in the same place 33 | 34 | ## Create & Access DB (PostgreSQL Windows) 35 | 36 | - **Windows WSL** 37 | 38 | ```cmd 39 | createdb -h localhost -p 5432 -U postgres [dbName] or if you got a working postgresql functions just execute: createdb [dbName] 40 | 41 | then... 42 | 43 | sudo -u postgres psql, then... \c [dbName] 44 | 45 | or 46 | 47 | psql -h localhost -p 5432 -U postgres -d [dbName] or if you want to login to postgres first: psql -U postgres then enter password 48 | ``` 49 | 50 | - **Ubuntu WSL Setup & PostgreSQL command guides** 51 | 52 | - [Getting Started with PostgreSQL](https://www.ntu.edu.sg/home/ehchua/programming/sql/PostgreSQL_GetStarted.html#zz-3.3) 53 | - [Getting Started With PostgreSQL 11 on Ubuntu 18.04](https://pgdash.io/blog/postgres-11-getting-started.html) 54 | - [Create and drop roles in PostgreSQL](https://support.rackspace.com/how-to/postgresql-creating-and-dropping-roles/) 55 | 56 | ## Implemented 57 | 58 | - Register - Done 59 | - Validation - Done 60 | - Login - Done 61 | - Authorization Roles - Done 62 | - Authorization Middleware - Done 63 | - Confirmation Email - Done 64 | - Forgot/Change Password - Done 65 | - Logout - Done 66 | - Test Environment (ts-jest) - Done 67 | - Pagination - Done 68 | - Rate Limiting - Done 69 | - Higher Order Resolver - 🏃 70 | - File/Image Multi-upload - Done - [Minor Issues](https://github.com/MichalLytek/type-graphql/issues/37) 71 | - Query Complexity - Done 72 | - Time Performance GraphQL Resolver - 🏃 73 | - Locking Accounts - 🏃 74 | - JWT Authentication (Access/Refresh Tokens, Revoking) - Done 75 | 76 | ## Plans 77 | 78 | - Will try to migrate to Rust 79 | 80 | ## Tests 81 | 82 | ```json 83 | // add this back if file upload won't work 84 | "resolutions": { 85 | "**/**/fs-capacitor": "^6.2.0", 86 | "**/graphql-upload": "^11.0.0" 87 | } 88 | ``` 89 | 90 | ## Sources 91 | 92 | - [The Problems of "Schema-First" GraphQL Server Development](https://www.prisma.io/blog/the-problems-of-schema-first-graphql-development-x1mn4cb0tyl3) 93 | - [graphql-upload with cloudinary](https://support.cloudinary.com/hc/en-us/community/posts/360031762832-graphql-upload-with-cloudinary) 94 | - [Fast and maintainable patterns for fetching from a database](https://sophiebits.com/2020/01/01/fast-maintainable-db-patterns.html) 95 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | container_name: example-web-server 9 | volumes: 10 | - ./src:/home/node/app/src 11 | - ./nodemon.json:/home/node/app/nodemon.json 12 | expose: 13 | - "8080" 14 | ports: 15 | - "8080:8080" 16 | - "9229:9229" 17 | command: npm start 18 | env_file: 19 | - ".env" 20 | redis: 21 | image: "redis:alpine3.12" 22 | ports: 23 | - "6379:6379" 24 | volumes: 25 | - "redisDB:/data" 26 | postgres: 27 | image: "postgres:12.3-alpine" 28 | env_file: 29 | - ".env" 30 | ports: 31 | - "5432:5432" 32 | volumes: 33 | - "postgresDB:/var/lib/postgresql/data" 34 | 35 | volumes: 36 | redisDB: {} 37 | postgresDB: {} 38 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src/'], 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: [ 6 | "**/**/?(*.)+(spec|test).+(ts)" 7 | ], 8 | "transform": { 9 | "^.+\\.(ts)$": "ts-jest" 10 | }, 11 | globals: { 12 | 'ts-jest': { 13 | tsConfig: 'tsconfig.jest.json' 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": ["src/**/*.spec.ts"], 4 | "watch": ["src/**/*.{ts|graphql}"], 5 | "execMap": { 6 | "ts": "node --inspect=0.0.0.0:9229 --nolazy -r ts-node-dev" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-ts-server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "dotenv -- ts-node-dev --respawn src/index.ts", 8 | "debug": "dotenv -- nodemon -e ts,graphql -x ts-node-dev --inspect src/index.ts", 9 | "build": "tsc -p ./", 10 | "build:run": "NODE_ENV=production node dist/src/index.js", 11 | "test": "yarn run db:setup && dotenv -- jest --verbose", 12 | "db:setup": "ts-node src/test-utils/setup.ts", 13 | "db:seed": "ts-node src/tasks/db/seed.ts", 14 | "lint": "eslint './src/**/*'", 15 | "format": "yarn run prettier -- --write", 16 | "prettier": "prettier \"**/*.+(js|ts|md|mdx|graphql|gql|json|yml|yaml)\"", 17 | "validate": "yarn run lint && yarn run prettier -- --list-different", 18 | "pre-commit": "lint-staged" 19 | }, 20 | "devDependencies": { 21 | "@types/faker": "^5.5.9", 22 | "@typescript-eslint/eslint-plugin": "^4.33.0", 23 | "@typescript-eslint/parser": "^4.33.0", 24 | "eslint": "7.21.0", 25 | "eslint-config-prettier": "^7.2.0", 26 | "eslint-plugin-prettier": "^3.4.1", 27 | "faker": "^5.5.3", 28 | "husky": "^5.2.0", 29 | "jest": "^26.6.3", 30 | "lint-staged": "^10.5.4", 31 | "nodemon": "^2.0.15", 32 | "prettier": "^2.5.1", 33 | "ts-jest": "^26.5.6", 34 | "ts-node": "9.1.1", 35 | "ts-node-dev": "^1.1.8" 36 | }, 37 | "dependencies": { 38 | "@types/bcryptjs": "^2.4.2", 39 | "@types/connect-redis": "^0.0.16", 40 | "@types/cookie-parser": "^1.4.2", 41 | "@types/cors": "^2.8.12", 42 | "@types/express": "^4.17.13", 43 | "@types/express-session": "^1.17.4", 44 | "@types/graphql-upload": "^8.0.11", 45 | "@types/ioredis": "^4.28.8", 46 | "@types/jest": "^26.0.24", 47 | "@types/jsonwebtoken": "^8.5.8", 48 | "@types/node": "^14.18.12", 49 | "@types/nodemailer": "^6.4.4", 50 | "@types/uuid": "^8.3.4", 51 | "apollo-server": "^2.25.3", 52 | "apollo-server-express": "^2.25.3", 53 | "bcryptjs": "^2.4.3", 54 | "class-validator": "^0.13.2", 55 | "connect-redis": "^5.2.0", 56 | "cookie-parser": "^1.4.6", 57 | "cors": "^2.8.5", 58 | "dataloader": "^2.0.0", 59 | "dotenv": "^8.6.0", 60 | "express": "^4.17.3", 61 | "express-session": "^1.17.2", 62 | "graphql": "15.5.0", 63 | "graphql-query-complexity": "^0.7.2", 64 | "graphql-upload": "^11.0.0", 65 | "ioredis": "^4.28.5", 66 | "jsonwebtoken": "^8.5.1", 67 | "nodemailer": "^6.7.2", 68 | "pg": "8.5.1", 69 | "reflect-metadata": "^0.1.13", 70 | "type-graphql": "1.1.1", 71 | "typeorm": "^0.2.43", 72 | "typescript": "^4.5.5", 73 | "uuid": "^8.3.2" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const sessionPrefix = 'sess:' 2 | export const userSessionIdPrefix = 'userSids:' 3 | export const confirmUserPrefix = 'user-confirmation' 4 | export const forgotPasswordPrefix = 'forget-password' 5 | export const fifteenMinutes = 60 * 15 6 | export const oneDay = 60 * 60 * 24 7 | export const oneWeek = 60 * 60 * 24 * 7 8 | -------------------------------------------------------------------------------- /src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | BaseEntity, 7 | BeforeInsert, 8 | BeforeUpdate, 9 | CreateDateColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm' 12 | import { ObjectType, Field, ID, Root, Authorized } from 'type-graphql' 13 | 14 | // https://typeorm.io/#/entities 15 | // INFO: Entity is a class that maps TypeScript code to a database table. 16 | // INFO: BaseEntity allows as to access the underlying prototype methods provided by typeorm 'active record pattern' (i.e User.create|find) 17 | // INFO: ObjectType allows us to create graphql object type from a class entity 18 | 19 | export enum Roles { 20 | ADMIN = 'ADMIN', 21 | USER = 'USER', 22 | ITEM_CREATE = 'ITEM_CREATE', 23 | ITEM_UPDATE = 'ITEM_UPDATE', 24 | ITEM_DELETE = 'ITEM_DELETE', 25 | PERMISSION_UPDATE = 'PERMISSION_UPDATE', 26 | } 27 | 28 | @ObjectType() 29 | @Entity('users') // you can set alternative table name by doing @Entity("users") 30 | export class User extends BaseEntity { 31 | // Field allows us to expose this property of entity into graphql object schema 32 | @Field(() => ID) 33 | @PrimaryGeneratedColumn() 34 | id: number 35 | 36 | // https://typeorm.io/#/entities/column-types-for-postgres 37 | 38 | @Field() 39 | @Column('varchar', { length: 128, name: 'first_name' }) 40 | firstName: string 41 | 42 | @Field() 43 | @Column('varchar', { length: 128, name: 'last_name' }) 44 | lastName: string 45 | 46 | @Field() 47 | @Column('text', { unique: true }) 48 | email: string 49 | 50 | @Column('text') password: string 51 | 52 | @Authorized('ADMIN') // Only admin can query this field 53 | @Field(() => [String]) 54 | @Column('enum', { enum: Roles, array: true, default: [Roles.USER] }) 55 | roles: Roles[] 56 | 57 | @Column('boolean', { default: false }) confirmed: boolean 58 | 59 | @Column('boolean', { default: false }) locked: boolean 60 | 61 | @Column('int', { default: 0, name: 'token_version' }) 62 | tokenVersion: number 63 | 64 | // We can also put a field w/o putting it on the database column if it is a simple field to query for User 65 | // and when it comes to relational querying you may as well put this on a separate Resolver Class, 66 | // About query complexity let's say this field causes high workload we need set its complexity points so whenever users 67 | // query this field 3 times, 3 x 3 = 9, if the maximum query complexity points is 8 then we should minimize querying this field 68 | @Field({ complexity: 3 }) 69 | name(@Root() parent: User): string { 70 | return `${parent.firstName} ${parent.lastName}` 71 | } 72 | 73 | // INFO: https://github.com/typeorm/typeorm/issues/1055 74 | // -> @OneToMany(target? => Child, child => child.parent) 75 | // @OneToMany(() => Post, post => post.user) 76 | // posts: Post[] 77 | 78 | @CreateDateColumn({ name: 'created_at' }) createdAt: string 79 | @UpdateDateColumn({ name: 'updated_at' }) updatedAt: string 80 | 81 | hashPassword(password = ''): Promise { 82 | return bcrypt.hash(password, 12) 83 | } 84 | 85 | comparePassword(password: string, hashedPassword: string): Promise { 86 | return bcrypt.compare(password, hashedPassword) 87 | } 88 | 89 | @BeforeInsert() 90 | @BeforeUpdate() 91 | async savePassword(): Promise { 92 | const hashedPassword = await this.hashPassword(this.password) 93 | this.password = hashedPassword 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/graphql-types/FieldError.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from 'type-graphql' 2 | 3 | @ObjectType() 4 | export class FieldError { 5 | @Field() 6 | path: string 7 | 8 | @Field() 9 | message: string 10 | } 11 | -------------------------------------------------------------------------------- /src/graphql-types/LoginResponse.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from 'type-graphql' 2 | import { FieldError } from './FieldError' 3 | import { User } from './../entity/User' 4 | import { IResponse } from './interfaces/Response' 5 | 6 | @ObjectType({ implements: IResponse }) 7 | export class LoginResponse implements IResponse { 8 | user?: User 9 | errors?: FieldError[] 10 | accessToken?: string 11 | } 12 | -------------------------------------------------------------------------------- /src/graphql-types/PaginatedUserResponse.ts: -------------------------------------------------------------------------------- 1 | import { User } from './../entity/User' 2 | import { PaginatedResponse } from './shared/PaginatedResponse' 3 | import { ObjectType } from 'type-graphql' 4 | 5 | @ObjectType() 6 | export class PaginatedUserResponse extends PaginatedResponse(User) { 7 | // we can freely add more fields or overwrite the existing one's types 8 | // @Field(type => [String]) 9 | // otherInfo: string[] 10 | } 11 | -------------------------------------------------------------------------------- /src/graphql-types/RegisterResponse.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from 'type-graphql' 2 | import { User } from '../entity/User' 3 | import { IResponse } from './interfaces/Response' 4 | 5 | @ObjectType({ implements: IResponse }) 6 | export class RegisterResponse implements IResponse { 7 | user?: User | undefined 8 | 9 | @Field(() => String) 10 | messageId: string 11 | 12 | @Field(() => String) 13 | messageUrl: string | boolean 14 | } 15 | -------------------------------------------------------------------------------- /src/graphql-types/UserResponse.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from 'type-graphql' 2 | import { User } from '../entity/User' 3 | import { FieldError } from './FieldError' 4 | import { IResponse } from './interfaces/Response' 5 | 6 | @ObjectType({ implements: IResponse }) 7 | export class UserResponse implements IResponse { 8 | user?: User 9 | errors?: FieldError[] 10 | accessToken?: string 11 | } 12 | -------------------------------------------------------------------------------- /src/graphql-types/interfaces/Response.ts: -------------------------------------------------------------------------------- 1 | import { FieldError } from '../FieldError' 2 | import { User } from '../../entity/User' 3 | import { InterfaceType, Field } from 'type-graphql' 4 | 5 | @InterfaceType() 6 | export abstract class IResponse { 7 | @Field(() => User, { nullable: true }) 8 | user?: User 9 | 10 | @Field(() => [FieldError], { nullable: true }) 11 | errors?: FieldError[] 12 | 13 | @Field({ nullable: true }) 14 | accessToken?: string 15 | } 16 | -------------------------------------------------------------------------------- /src/graphql-types/shared/PaginatedResponse.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { ClassType, Field, ObjectType, Int } from 'type-graphql' 3 | 4 | export const PaginatedResponse = (TItemClass: ClassType) => { 5 | // `isAbstract` decorator option is mandatory to prevent registering in schema 6 | @ObjectType({ isAbstract: true }) 7 | abstract class PaginatedResponseClass { 8 | @Field(() => [TItemClass]) 9 | results: TItem[] 10 | 11 | @Field(() => Int) 12 | total: number 13 | 14 | @Field({ nullable: true }) 15 | next?: string 16 | 17 | @Field({ nullable: true }) 18 | previous?: string 19 | } 20 | return PaginatedResponseClass as ClassType // asserting it as ClassType for now, Bug TS 3.7.3 Compiler 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | import 'reflect-metadata' 3 | import { createSchema } from './utils/createSchema' 4 | import { createAccessToken, createRefreshToken } from './utils/authToken' 5 | import { verify } from 'jsonwebtoken' 6 | import express from 'express' 7 | import { ApolloServer } from 'apollo-server-express' 8 | import { createConnection } from 'typeorm' 9 | import cors from 'cors' 10 | import cookieParser from 'cookie-parser' 11 | import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity' 12 | 13 | // import session from 'express-session' 14 | // import connectRedis from 'connect-redis' 15 | // import { redis } from './redis' 16 | 17 | import { typeOrmConfig } from './utils/ormconfig' 18 | import { User } from './entity/User' 19 | import { sendRefreshToken } from './utils/sendRefreshToken' 20 | import { separateOperations } from 'graphql' 21 | 22 | const startServer = async (): Promise => { 23 | // INFO: Here you can setup and run express/koa/any other framework 24 | const app = express() 25 | // const RedisStore = connectRedis(session) 26 | 27 | // INFO: Add routes|middleware here for http requests in sequence order: middleware then graphql resolver / routes 28 | // app.use('/auth' | cors | routes, cb(..args)) 29 | 30 | // CORS is needed to perform HTTP requests from another domain than your server domain to your server. 31 | // Otherwise you may run into cross-origin resource sharing errors for your GraphQL server. 32 | 33 | // NOTE: Additional middleware can be mounted at this point to run before getting into resolvers. 34 | app.use( 35 | cors({ 36 | origin: process.env.FRONTEND_URL, 37 | credentials: true, // send back cookie 38 | }), 39 | ) 40 | 41 | // #region Session Middleware - Uncomment this if you want to use session for authentication 42 | // app.use( 43 | // session({ 44 | // store: new RedisStore({ 45 | // client: redis, 46 | // }), 47 | // name: 'sid', 48 | // secret: process.env.SESSION_SECRET || 'ww2wde1q2321', 49 | // resave: false, 50 | // saveUninitialized: false, 51 | // cookie: { 52 | // httpOnly: true, 53 | // secure: process.env.NODE_ENV === 'production', // https 54 | // maxAge: 1000 * 60 * 60 * 24 * 7 * 365, // 7 years 55 | // }, 56 | // }), 57 | // ) 58 | // #endregion 59 | 60 | app.use('/refresh_token', cookieParser()) // so it only runs on refresh_token route that need it 61 | 62 | // Handle refresh token 63 | app.post('/refresh_token', async (req, res) => { 64 | // get refresh token stored in a cookie called jid 65 | const token = req.cookies.jid 66 | 67 | if (!token) { 68 | // don't send back access token 69 | return res.send({ ok: false, accessToken: '' }) 70 | } 71 | 72 | let payload: any = null 73 | try { 74 | // make sure refresh token is still valid, otherwise revoke refresh token (when user change his/her password) 75 | payload = verify(token, process.env.REFRESH_TOKEN_SECRET!) 76 | } catch (err) { 77 | console.log({ error: err.name, message: err.message }) 78 | return res.send({ ok: false, accessToken: '' }) 79 | } 80 | 81 | // token is valid and we can send back an access token 82 | const user = await User.findOne({ where: { id: payload.userId } }) 83 | 84 | // if can't find user 85 | if (!user) { 86 | return res.send({ ok: false, accessToken: '' }) 87 | } 88 | 89 | // if token version change 90 | if (user.tokenVersion !== payload.tokenVersion) { 91 | return res.send({ ok: false, accessToken: '' }) 92 | } 93 | 94 | // if all is well, renew refresh token too 95 | sendRefreshToken(res, createRefreshToken(user)) 96 | 97 | return res.send({ ok: true, accessToken: createAccessToken(user) }) 98 | }) 99 | 100 | let retries = 5 101 | 102 | while (retries) { 103 | try { 104 | await createConnection(typeOrmConfig) 105 | break 106 | } catch (err) { 107 | console.log(err) 108 | retries -= 1 109 | console.log(`retries left: ${retries}`) 110 | await new Promise((res) => setTimeout(res, 5000)) 111 | } 112 | } 113 | 114 | const schema = await createSchema() 115 | 116 | const server = new ApolloServer({ 117 | schema, 118 | // A graph APIs context is an object that's shared among every resolver in a GraphQL request 119 | context: ({ req, res }) => ({ 120 | // this is the req and res from express, we can access these in the resolvers when using sessions / jwt 121 | req, 122 | res, 123 | // you can add also data loaders, pubsub, etc. along with req & res 124 | }), 125 | // dataSources: {}, // external rest apis, https://www.apollographql.com/docs/tutorial/data-source/ 126 | // Create a plugin that will allow for query complexity calculation for every request 127 | plugins: [ 128 | { 129 | requestDidStart: () => ({ 130 | didResolveOperation({ request, document }) { 131 | /** 132 | * This provides GraphQL query analysis to be able to react on complex queries to your GraphQL server. 133 | * This can be used to protect your GraphQL servers against resource exhaustion and DoS attacks. 134 | * More documentation can be found at https://github.com/ivome/graphql-query-complexity. 135 | */ 136 | const complexity = getComplexity({ 137 | // Our built schema 138 | schema, 139 | // To calculate query complexity properly, 140 | // we have to check if the document contains multiple operations 141 | // and eventually extract it operation from the whole query document. 142 | query: request.operationName ? separateOperations(document)[request.operationName] : document, 143 | // The variables for our GraphQL query 144 | variables: {}, 145 | // Add any number of estimators. The estimators are invoked in order, the first 146 | // numeric value that is being returned by an estimator is used as the field complexity. 147 | // If no estimator returns a value, an exception is raised. 148 | estimators: [ 149 | // Using fieldExtensionsEstimator is mandatory to make it work with type-graphql. 150 | fieldExtensionsEstimator(), 151 | // Add more estimators here... 152 | // This will assign each field a complexity of 1 153 | // if no other estimator returned a value. 154 | simpleEstimator({ defaultComplexity: 1 }), 155 | ], 156 | }) 157 | // Here we can react to the calculated complexity, 158 | // like compare it with max and throw error when the threshold is reached. 159 | if (complexity >= 20) { 160 | throw new Error( 161 | `Sorry, too complicated query! ${complexity} is over 8 that is the max allowed complexity.`, 162 | ) 163 | } 164 | // And here we can e.g. subtract the complexity point from hourly API calls limit. 165 | console.log('Used query complexity points:', complexity) 166 | }, 167 | }), 168 | }, 169 | ], 170 | }) 171 | 172 | // Using Apollo Server’s applyMiddleware() method, you can opt-in any middleware, which in this case is Express 173 | // INFO: What this does is it creates a bunch of middleware for express app under the hood; 174 | server.applyMiddleware({ 175 | app, // app is from an existing express app 176 | path: '/graphql', 177 | cors: false, // set apollo cors false, cause we include third party middleware 'cors' 178 | }) 179 | 180 | app.listen(process.env.PORT || 4000, (): void => { 181 | console.log('🚀 Server ready at http://localhost:4000' + server.graphqlPath) 182 | }) 183 | } 184 | 185 | startServer().catch((err) => console.log(err)) 186 | -------------------------------------------------------------------------------- /src/modules/admin/Admin.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedUserResponse } from '../../graphql-types/PaginatedUserResponse' 2 | // import { isAuth } from './../middleware/isAuth' 3 | import { Resolver, Query, Mutation, Arg } from 'type-graphql' 4 | import { User } from '../../entity/User' 5 | import { getConnection } from 'typeorm' 6 | 7 | @Resolver() 8 | export class AdminResolver { 9 | // @Query(returns => PaginatedUserResponse, { nullable: true }) 10 | // @UseMiddleware(isAuth) 11 | // @Authorized('ADMIN') 12 | // async users(@Arg('page', () => Number, { nullable: true }) page = 1): Promise { 13 | // // https://stackoverflow.com/questions/53922503/how-to-implement-pagination-in-nestjs-with-typeorm 14 | // const [results, total] = await User.findAndCount({ order: { createdAt: 'DESC' }, take: 2, skip: (page - 1) * 2 }) 15 | // return { 16 | // results, 17 | // total, 18 | // } 19 | // 20 | 21 | @Query(() => PaginatedUserResponse, { nullable: true }) 22 | async users(@Arg('page', () => Number, { nullable: true }) page = 1): Promise { 23 | // https://stackoverflow.com/questions/53922503/how-to-implement-pagination-in-nestjs-with-typeorm 24 | const [results, total] = await User.findAndCount({ order: { createdAt: 'DESC' }, take: 2, skip: (page - 1) * 2 }) 25 | return { 26 | results, 27 | total, 28 | } 29 | } 30 | 31 | @Mutation(() => Boolean) 32 | async revokeRefreshTokenForUser(@Arg('userId') userId: string) { 33 | await getConnection() 34 | .getRepository(User) 35 | .increment({ id: parseInt(userId) }, 'tokenVersion', 1) 36 | return true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/hello/Hello.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, UseMiddleware } from 'type-graphql' 2 | import { isAuth } from '../middleware/isAuth' 3 | 4 | @Resolver() 5 | export class HelloResolver { 6 | @Query(() => String) 7 | @UseMiddleware(isAuth) 8 | hello() { 9 | return 'Hello World!' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/middleware/isAuth.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express' 2 | import { MiddlewareFn } from 'type-graphql' 3 | import { verify } from 'jsonwebtoken' 4 | import { IContext } from '../../types/MyContext' 5 | 6 | // INFO: Here we create own middleware instead of using global authChecker provided in buildSchema 7 | export const isAuth: MiddlewareFn = async ({ context }, next) => { 8 | const authorization = context.req.headers['authorization'] // bearer token 9 | 10 | if (!authorization) { 11 | return null // let try catch handle authentication error 12 | } 13 | 14 | try { 15 | const token = authorization.split(' ')[1] 16 | const payload = verify(token, process.env.ACCESS_TOKEN_SECRET!) // decoded Token 17 | context.payload = payload as { userId: number } // store userId in context payload 18 | } catch (err) { 19 | const error = new Error() 20 | error.name = err.name 21 | error.message = 'Not authenticated' 22 | error.stack = new AuthenticationError(err.message).stack 23 | return error 24 | } 25 | 26 | // #region - uncomment if using session 27 | // if (!context.req.session.userId) { 28 | // throw new AuthenticationError('not authenticated') 29 | // } 30 | // #endregion 31 | 32 | return next() 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/middleware/logger.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql' 2 | import { IContext } from '../../types/MyContext' 3 | 4 | // INFO: Here we create own middleware instead of using authCher provided in buildSchema 5 | export const logger: MiddlewareFn = async ({ root, args, context, info }, next) => { 6 | console.log('Args: ', args) 7 | 8 | return next() 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/middleware/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from 'type-graphql' 2 | import { redis } from '../../redis' 3 | import { IContext } from '../../types/MyContext' 4 | // import { oneDay } from '../../constants' 5 | 6 | export const rateLimit: (limitForAnonUser?: number, limitForUser?: number) => MiddlewareFn = ( 7 | limitForAnonUser = 5, // limit for anonymous users 8 | limitForUser = 100, // limit for regular users 9 | ) => async ({ context, info }, next) => { 10 | const isAnon = !context?.payload?.userId 11 | const visitorKey = `rate-limit:${info.fieldName}:${isAnon ? context.req.ip : context.payload.userId}` 12 | // const isAnon = !req.session.userId 13 | // const visitorKey = `rate-limit:${info.fieldName}:${isAnon ? req.ip : req.session.userId}` 14 | 15 | // keep track of number of times the user has request a resolver using redis increment 16 | const current = await redis.incr(visitorKey) 17 | 18 | if ((isAnon && current > limitForAnonUser) || (!isAnon && current > limitForUser)) { 19 | throw new Error("you're doing that too much") 20 | } else if (current === 1) { 21 | // current key has been initialized by redis so set its expiration to 1 day for that particular user 22 | await redis.expire(visitorKey, 60) 23 | } 24 | 25 | return next() 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/shared/PasswordComplexity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator' 3 | import { IPasswordConstraint } from '../../types/PasswordConstraint' 4 | import { passwordValidator } from '../../utils/passwordValidator' 5 | 6 | export function PasswordComplexity(property: IPasswordConstraint, validationOptions?: ValidationOptions) { 7 | return function (object: Record, propertyName: string) { 8 | registerDecorator({ 9 | name: 'PasswordComplexity', 10 | target: object.constructor, 11 | propertyName: propertyName, 12 | constraints: [property], 13 | options: validationOptions, 14 | validator: { 15 | validate(value: string, args: ValidationArguments) { 16 | const { passed } = passwordValidator(args.constraints[0], value) 17 | if (!passed) return false 18 | // if password pass all complexity return true 19 | return true 20 | }, 21 | }, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/shared/PasswordMixin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { InputType, Field, ClassType } from 'type-graphql' 3 | import { ValidationArguments, MinLength, MaxLength } from 'class-validator' 4 | import { PasswordComplexity } from './PasswordComplexity' 5 | import { passwordValidator } from '../../utils/passwordValidator' 6 | 7 | // INFO: Here we create Input Type Mixins where we extend multiple input type classes 8 | 9 | export const PasswordMixin = (baseClass: T) => { 10 | @InputType({ isAbstract: true }) 11 | class PasswordInput extends baseClass { 12 | @Field() 13 | @MinLength(6) 14 | @MaxLength(12) 15 | @PasswordComplexity( 16 | { upperCase: true, lowerCase: true, digit: true, specialCharacter: true }, 17 | { 18 | message: (args: ValidationArguments) => { 19 | const { passed, message } = passwordValidator(args.constraints[0], args.value) 20 | 21 | if (!passed) { 22 | return message as string 23 | } 24 | 25 | return 'Good to go!' 26 | }, 27 | }, 28 | ) 29 | password: string 30 | } 31 | 32 | return PasswordInput 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/user/ChangePassword.ts: -------------------------------------------------------------------------------- 1 | import { sendRefreshToken } from '../../utils/sendRefreshToken' 2 | import { createAccessToken, createRefreshToken } from './../../utils/authToken' 3 | import { getConnection } from 'typeorm' 4 | import { Resolver, Mutation, Arg, Ctx } from 'type-graphql' 5 | import { User } from '../../entity/User' 6 | import { redis } from '../../redis' 7 | import { forgotPasswordPrefix } from '../../constants' 8 | import { ChangePasswordInput } from './changePassword/ChangePasswordInput' 9 | import { IContext } from '../../types/MyContext' 10 | import { UserResponse } from '../../graphql-types/UserResponse' 11 | 12 | @Resolver() 13 | export class ChangePasswordResolver { 14 | @Mutation(() => UserResponse) 15 | async changePassword( 16 | @Arg('data') { token, password }: ChangePasswordInput, 17 | @Ctx() { res }: IContext, 18 | ): Promise { 19 | // get the user id from redis 20 | const userId = await redis.get(forgotPasswordPrefix + token) 21 | 22 | if (!userId) { 23 | return { 24 | errors: [ 25 | { 26 | path: 'Change Password', 27 | message: 'Token is invalid.', 28 | }, 29 | ], 30 | } 31 | } 32 | 33 | const user = await User.findOne(userId) 34 | 35 | if (!user) { 36 | return { 37 | errors: [ 38 | { 39 | path: 'Change Password', 40 | message: 'User not found.', 41 | }, 42 | ], 43 | } 44 | } 45 | 46 | // delete forgot password confirmation 47 | await redis.del(forgotPasswordPrefix + token) 48 | 49 | // update user password 50 | await User.update({ id: parseInt(userId) }, { password }) 51 | 52 | // login automatically the user 53 | // ctx.req.session.userId = user.id - session 54 | 55 | // increment token version, so current refresh token will be invalidated 56 | // whenever '/refresh-token' route called it won't generate new access token to current logged-in devices 57 | await getConnection() 58 | .getRepository(User) 59 | .increment({ id: parseInt(userId) }, 'tokenVersion', 1) 60 | 61 | // create and send new refresh token 62 | sendRefreshToken(res, createRefreshToken(user)) 63 | 64 | // send new access token, so it will login automatically in the front-end 65 | return { user, accessToken: createAccessToken(user) } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/modules/user/ConfirmUser.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg } from 'type-graphql' 2 | import { User } from '../../entity/User' 3 | import { redis } from '../../redis' 4 | import { confirmUserPrefix } from '../../constants' 5 | 6 | @Resolver() 7 | export class ConfirmUserResolver { 8 | @Mutation(() => Boolean) 9 | async confirmUser(@Arg('token') token: string): Promise { 10 | const userId = await redis.get(confirmUserPrefix + token) // get the value userId 11 | 12 | if (!userId) return false 13 | 14 | await User.update({ id: parseInt(userId) }, { confirmed: true }) 15 | 16 | await redis.del(confirmUserPrefix + token) 17 | 18 | return true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/user/FileUpload.ts: -------------------------------------------------------------------------------- 1 | // import { Upload } from '../../types/Upload' 2 | import { Resolver, Mutation, Arg } from 'type-graphql' 3 | import { GraphQLUpload, FileUpload } from 'graphql-upload' 4 | import path from 'path' 5 | import { createWriteStream } from 'fs' 6 | // import shortid from 'shortid' 7 | 8 | @Resolver() 9 | export class FileUploadResolver { 10 | @Mutation(() => [Boolean]) 11 | async fileUpload(@Arg('files', () => GraphQLUpload) files: FileUpload): Promise { 12 | let readableStreams: FileUpload[] = [] 13 | if (Array.isArray(files)) { 14 | readableStreams = await Promise.all(files) 15 | console.log(readableStreams) 16 | } else { 17 | readableStreams[0] = files 18 | } 19 | 20 | const pipeStreams = readableStreams.map(async (readStreamInstance) => { 21 | const { filename, createReadStream } = readStreamInstance 22 | const writableStream = createWriteStream(path.join(__dirname, '../../../images', filename), { autoClose: true }) 23 | return await new Promise((resolve, reject) => 24 | createReadStream() 25 | .pipe(writableStream) 26 | .on('error', () => reject(false)) 27 | .on('finish', () => resolve(true)), 28 | ) 29 | }) 30 | 31 | return await Promise.all(pipeStreams) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/user/ForgotPassword.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg } from 'type-graphql' 2 | import { v4 as uuid } from 'uuid' 3 | import { User } from '../../entity/User' 4 | // import { IContext } from '../../types/MyContext' 5 | import { redis } from '../../redis' 6 | import { sendEmail } from '../../utils/sendEmail' 7 | import { forgotPasswordPrefix } from '../../constants' 8 | 9 | @Resolver() 10 | export class ForgotPasswordResolver { 11 | @Mutation(() => Boolean) 12 | // async forgotPassword(@Arg('email') email: string, @Ctx() ctx: IContext): Promise { 13 | async forgotPassword(@Arg('email') email: string): Promise { 14 | const user = await User.findOne({ where: { email } }) 15 | if (!user) throw new Error("Email address doesn't exist") 16 | 17 | const token = uuid() 18 | // INFO: set redis key:value pair, value is going to be the person who will need to confirm 19 | await redis.set(forgotPasswordPrefix + token, user.id, 'ex', 60 * 60 * 24) // 1 day expiration 20 | 21 | // URL will redirect back to frontend, frontend will call a mutation 22 | await sendEmail(email, `http://localhost:3000/user/change-password/${token}`) 23 | 24 | return true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/user/Login.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg, Ctx } from 'type-graphql' 2 | import { User } from '../../entity/User' 3 | import { IContext } from '../../types/MyContext' 4 | import { LoginResponse } from '../../graphql-types/LoginResponse' 5 | import { createAccessToken, createRefreshToken } from '../../utils/authToken' 6 | import { sendRefreshToken } from '../../utils/sendRefreshToken' 7 | 8 | @Resolver() 9 | export class LoginResolver { 10 | @Mutation(() => LoginResponse) 11 | async login( 12 | @Arg('email') email: string, 13 | @Arg('password') password: string, 14 | @Ctx() { res }: IContext, 15 | ): Promise { 16 | const user = await User.findOne({ where: { email } }) 17 | 18 | if (!user) { 19 | return { 20 | errors: [ 21 | { 22 | path: 'Email', 23 | message: 'User not found with the email address you provided.', 24 | }, 25 | ], 26 | } 27 | } 28 | 29 | const valid = await user.comparePassword(password, user.password) 30 | 31 | if (!valid) { 32 | return { 33 | errors: [ 34 | { 35 | path: 'Password', 36 | message: 'Your password is invalid, please try again.', 37 | }, 38 | ], 39 | } 40 | } 41 | 42 | if (!user.confirmed) { 43 | return { 44 | errors: [ 45 | { 46 | path: 'Confirmation', 47 | message: 'User not confirmed.', 48 | }, 49 | ], 50 | } 51 | } 52 | 53 | // #region - uncomment if you wanna use session 54 | // add userId to session object & this session id will be stored in Redis and its going to persist that 55 | // ctx.req.session.userId = user.id 56 | // no need session.save() - useful to call this method (i.e redirects, long-lived request or in websocket) 57 | // #endregion 58 | 59 | // login successful - create and send refresh token 60 | 61 | sendRefreshToken(res, createRefreshToken(user)) 62 | 63 | return { 64 | accessToken: createAccessToken(user), 65 | user, 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/user/Logout.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Ctx, UseMiddleware } from 'type-graphql' 2 | import { IContext } from '../../types/MyContext' 3 | import { isAuth } from '../middleware/isAuth' 4 | import { sendRefreshToken } from '../../utils/sendRefreshToken' 5 | 6 | @Resolver() 7 | export class LogoutResolver { 8 | @Mutation(() => Boolean) 9 | @UseMiddleware(isAuth) 10 | async logout(@Ctx() { res }: IContext): Promise { 11 | // return new Promise((resolve, reject) => 12 | // ctx.req.session.destroy(err => { 13 | // if (err) { 14 | // console.log(err) 15 | // return reject(false) 16 | // } 17 | // 18 | // ctx.res.clearCookie('jid') // clear cookie 19 | // return resolve(true) 20 | // }), 21 | // ) 22 | sendRefreshToken(res, '') 23 | // res.clearCookie('jid') // or you can use clear cookie 24 | return true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/user/Me.ts: -------------------------------------------------------------------------------- 1 | import { isAuth } from '../middleware/isAuth' 2 | import { Resolver, Query, Ctx, UseMiddleware } from 'type-graphql' 3 | import { User } from '../../entity/User' 4 | import { IContext } from '../../types/MyContext' 5 | 6 | @Resolver() 7 | export class MeResolver { 8 | @Query(() => User, { nullable: true, complexity: 2 }) 9 | @UseMiddleware(isAuth) 10 | async me(@Ctx() { payload }: IContext): Promise { 11 | // if (!ctx.req.session.userId) { 12 | 13 | const user = await User.findOne({ where: { id: payload.userId } }) 14 | 15 | return user! 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/user/Register.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg, UseMiddleware } from 'type-graphql' 2 | import { User } from '../../entity/User' 3 | import { RegisterInput } from './register/RegisterInput' 4 | import { rateLimit } from '../middleware/rateLimit' 5 | import { sendEmail } from '../../utils/sendEmail' 6 | import { createConfirmationUrl } from '../../utils/createConfirmationUrl' 7 | import { RegisterResponse } from '../../graphql-types/RegisterResponse' 8 | 9 | // https://typegraphql.com/docs/resolvers.html 10 | // Specifying User in Resolver parameter so we can know where this `name` field resolver are resolving by, so its resolving for User 11 | // @Resolver(of => User) 12 | @Resolver() 13 | export class RegisterResolver { 14 | // @FieldResolver() 15 | // async name(@Root() parent: User) { 16 | // return `${parent.firstName} ${parent.lastName}` 17 | // } 18 | 19 | @Mutation(() => RegisterResponse, { description: 'register reasonably', nullable: true }) 20 | @UseMiddleware(rateLimit()) // add rate-limiting to prevent spamming, keep track of that person's ip 21 | async register( 22 | @Arg('data', { validate: true }) { firstName, lastName, email, password }: RegisterInput, 23 | ): Promise { 24 | // const hashedPassword = await bcrypt.hash(password, 12) 25 | let user 26 | let confirmationResponse: RegisterResponse 27 | 28 | try { 29 | user = await User.create({ 30 | firstName, 31 | lastName, 32 | email, 33 | password, 34 | }).save() 35 | 36 | confirmationResponse = await sendEmail(email, await createConfirmationUrl(user.id)) 37 | } catch (err) { 38 | console.log(err) 39 | } 40 | 41 | return { user, ...confirmationResponse! } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/user/changePassword/ChangePasswordInput.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from 'type-graphql' 2 | import { PasswordMixin } from '../../shared/PasswordMixin' 3 | 4 | // INFO: Here we create Input Type 5 | @InputType() 6 | export class ChangePasswordInput extends PasswordMixin(class {}) { 7 | @Field() 8 | token: string 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/user/me/Me.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'typeorm' 2 | import faker from 'faker' 3 | import { createAccessToken } from '../../../utils/authToken' 4 | import { User } from '../../../entity/User' 5 | import { redis } from '../../../redis' 6 | import { gCall } from '../../../test-utils/gCall' 7 | import { testConn } from '../../../test-utils/testConn' 8 | 9 | let conn: Connection 10 | 11 | beforeAll(async () => { 12 | conn = await testConn() 13 | console.log('Redis Status: ', redis.status) 14 | }) 15 | 16 | const meQuery = ` 17 | { 18 | me { 19 | id 20 | firstName 21 | lastName 22 | email 23 | } 24 | } 25 | ` 26 | 27 | describe('Me', () => { 28 | it('get user', async () => { 29 | const user = await User.create({ 30 | firstName: faker.name.firstName(), 31 | lastName: faker.name.lastName(), 32 | email: faker.internet.email(), 33 | }) 34 | 35 | gCall({ 36 | source: meQuery, 37 | accessToken: createAccessToken(user), 38 | }).then(async (response) => { 39 | expect(response).toMatchObject({ 40 | data: { 41 | me: { 42 | id: user.id, 43 | firstName: user.firstName, 44 | lastName: user.lastName, 45 | email: user.email, 46 | }, 47 | }, 48 | }) 49 | }) 50 | }) 51 | 52 | it('return null', async () => { 53 | gCall({ 54 | source: meQuery, 55 | }).then(async (response) => { 56 | expect(response).toMatchObject({ 57 | data: { 58 | me: null, 59 | }, 60 | }) 61 | }) 62 | }) 63 | }) 64 | 65 | afterAll(async () => { 66 | // close all database connections, redis included 67 | await conn.close() 68 | redis.disconnect() 69 | }) 70 | -------------------------------------------------------------------------------- /src/modules/user/register/Register.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'typeorm' 2 | import faker from 'faker' 3 | import { redis } from '../../../redis' 4 | import { User } from '../../../entity/User' 5 | import { gCall } from '../../../test-utils/gCall' 6 | import { testConn } from '../../../test-utils/testConn' 7 | 8 | let conn: Connection 9 | 10 | beforeAll(async () => { 11 | conn = await testConn() 12 | }) 13 | 14 | const registerMutation = ` 15 | mutation Register($data: RegisterInput!) { 16 | register(data: $data) { 17 | firstName 18 | lastName 19 | email 20 | } 21 | } 22 | ` 23 | 24 | describe('Register', () => { 25 | it('create user', async () => { 26 | const user = { 27 | firstName: faker.name.firstName(), 28 | lastName: faker.name.lastName(), 29 | email: faker.internet.email(), 30 | password: 'wWeb!23', 31 | } 32 | 33 | gCall({ 34 | source: registerMutation, 35 | variableValues: { 36 | data: user, 37 | }, 38 | }).then(async (response) => { 39 | const dbUser = await User.findOne({ where: { email: response.data?.email } }) 40 | 41 | expect(response).toMatchObject({ 42 | data: { 43 | register: { 44 | firstName: user.firstName, 45 | lastName: user.lastName, 46 | email: user.email, 47 | }, 48 | }, 49 | }) 50 | 51 | expect(dbUser).toBeDefined() 52 | expect(dbUser?.confirmed).toBeFalsy() // make sure user is not confirm 53 | }) 54 | }) 55 | }) 56 | 57 | afterAll(async () => { 58 | // close all database connections, redis included 59 | await conn.close() 60 | redis.disconnect() 61 | }) 62 | -------------------------------------------------------------------------------- /src/modules/user/register/RegisterInput.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from 'type-graphql' 2 | import { Length, IsEmail } from 'class-validator' 3 | import { isEmailAlreadyExist } from './isEmailAlreadyExist' 4 | import { PasswordMixin } from '../../shared/PasswordMixin' 5 | 6 | // INFO: Here we create Input Type 7 | @InputType() 8 | export class RegisterInput extends PasswordMixin(class {}) { 9 | @Field() 10 | @Length(1, 255) 11 | firstName: string 12 | 13 | @Field() 14 | @Length(1, 255) 15 | lastName: string 16 | 17 | @Field() 18 | @IsEmail() 19 | @isEmailAlreadyExist({ message: 'Email $value already exists.' }) 20 | email: string 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/user/register/isEmailAlreadyExist.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | } from 'class-validator' 7 | import { User } from '../../../entity/User' 8 | 9 | // INFO: This Validator constraint will be use in custom validator 10 | @ValidatorConstraint({ async: true }) 11 | export class IsEmailAlreadyExistConstraint implements ValidatorConstraintInterface { 12 | async validate(email: string) { 13 | const userExist = await User.findOne({ where: { email } }) 14 | 15 | if (userExist) return false 16 | 17 | return true 18 | } 19 | } 20 | 21 | // INFO: Here we create custom validator using class-validator 22 | export function isEmailAlreadyExist(validationOptions?: ValidationOptions) { 23 | return function(object: Object, propertyName: string) { // eslint-disable-line 24 | registerDecorator({ 25 | target: object.constructor, 26 | propertyName: propertyName, 27 | options: validationOptions, 28 | constraints: [], 29 | validator: IsEmailAlreadyExistConstraint, 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | 3 | export const redis = new Redis() 4 | -------------------------------------------------------------------------------- /src/tasks/db/seed.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { createConnection, Connection } from 'typeorm' 4 | 5 | import { typeOrmConfig } from '../../ormconfig' 6 | import { Roles, User } from '../../entity/User' 7 | 8 | const startServer = async () => { 9 | console.log('Beginning db seed task.') 10 | 11 | const connection: Connection = await createConnection(typeOrmConfig) 12 | console.log('PG connected.') 13 | 14 | // Create seed data. 15 | console.log('Inserting a new users into the database...') 16 | await connection 17 | .createQueryBuilder() 18 | .insert() 19 | .into(User) 20 | .values([ 21 | { 22 | firstName: 'Barry', 23 | lastName: 'Blando', 24 | email: 'barryblando@gmail.com', 25 | password: 'Weq!71', 26 | confirmed: true, 27 | roles: [Roles.ADMIN], 28 | }, 29 | { 30 | firstName: 'Gabriela', 31 | lastName: 'Leskur', 32 | email: 'gabrielaleskur@gmail.com', 33 | password: 'Qeq!81', 34 | confirmed: true, 35 | }, 36 | ]) 37 | .execute() 38 | 39 | console.log('Loading users from the database...') 40 | const users = await connection.manager.find(User) 41 | console.log('Loaded users: ', users) 42 | 43 | // Close connection 44 | await connection.close() 45 | console.log('PG connection closed.') 46 | 47 | console.log('Finished db seed task.') 48 | } 49 | 50 | startServer().catch(err => console.log(err)) 51 | -------------------------------------------------------------------------------- /src/test-utils/gCall.ts: -------------------------------------------------------------------------------- 1 | import { createSchema } from './../utils/createSchema' 2 | import { graphql, GraphQLSchema } from 'graphql' 3 | import { Maybe } from 'type-graphql' 4 | 5 | interface IOptions { 6 | // query, mutation, subscription 7 | source: string 8 | // graphql data arguments 9 | variableValues?: Maybe<{ 10 | [key: string]: any 11 | }> 12 | accessToken?: string 13 | } 14 | 15 | let schema: GraphQLSchema 16 | 17 | export const gCall = async ({ source, variableValues, accessToken }: IOptions) => { 18 | if (!schema) { 19 | // INFO: In order to test the query, mutation resolvers from type-graphql 20 | // we need to create separate graphql schema and make separate graphql calls 21 | schema = await createSchema() 22 | } 23 | 24 | return await graphql({ 25 | schema, 26 | source, 27 | variableValues, 28 | // here we can provide context values when calling graphql 29 | contextValue: { 30 | req: { 31 | headers: { 32 | authorization: 'bearer ' + accessToken, 33 | }, 34 | }, 35 | res: { 36 | clearCookie: jest.fn(), 37 | }, 38 | }, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/test-utils/setup.ts: -------------------------------------------------------------------------------- 1 | import { testConn } from './testConn' 2 | 3 | testConn(true).then(() => process.exit()) 4 | -------------------------------------------------------------------------------- /src/test-utils/testConn.ts: -------------------------------------------------------------------------------- 1 | import { createConnection } from 'typeorm' 2 | 3 | export const testConn = (drop = false) => { 4 | return createConnection({ 5 | type: 'postgres', 6 | host: '127.0.0.1', 7 | port: 5432, 8 | username: 'bblando0x316', 9 | password: 'postgres', 10 | database: 'testdb-dev', 11 | synchronize: drop, 12 | dropSchema: drop, 13 | entities: [__dirname + '/../entity/**/*.*'], 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/types/MyContext.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | interface ISession { 4 | session?: { 5 | userId?: number 6 | destroy(callback: (err: any) => void): void 7 | } 8 | } 9 | 10 | interface IHeaders { 11 | headers?: { 12 | [propName: string]: any 13 | authorization?: string 14 | } 15 | } 16 | 17 | // interface to use for rate-limiting 18 | interface IP { 19 | ip?: string 20 | } 21 | 22 | export interface IContext { 23 | req: express.Request & ISession & IP & IHeaders 24 | res: express.Response 25 | payload: { userId: number; tokenVersion?: number } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/PasswordConstraint.ts: -------------------------------------------------------------------------------- 1 | export interface IPasswordConstraint { 2 | upperCase?: boolean 3 | lowerCase?: boolean 4 | digit?: boolean 5 | specialCharacter?: boolean 6 | } 7 | -------------------------------------------------------------------------------- /src/types/ResolverType.ts: -------------------------------------------------------------------------------- 1 | export type Resolver = (parent: any, args: any, context: any, info: any) => any 2 | 3 | // Use this interface if using schema-first graphql 4 | export interface IResolverMap { 5 | [key: string]: { 6 | [key: string]: Resolver 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/types/Upload.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'stream' 2 | 3 | export interface IUpload { 4 | filename: string 5 | mimetype: string 6 | encoding: string 7 | createReadStream: () => Stream 8 | } 9 | -------------------------------------------------------------------------------- /src/types/uuid.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'uuid/v4'; 2 | -------------------------------------------------------------------------------- /src/utils/authChecker.ts: -------------------------------------------------------------------------------- 1 | import { AuthChecker } from 'type-graphql' 2 | import { User } from '../entity/User' 3 | import { IContext } from '../types/MyContext' 4 | import { verify } from 'jsonwebtoken' 5 | 6 | export const authChecker: AuthChecker = async ({ context }, roles): Promise => { 7 | const authorization = context.req.headers['authorization'] // bearer token 8 | 9 | if (!authorization) { 10 | return false // Access denied 11 | } 12 | 13 | const token = authorization.split(' ')[1] 14 | const payload = verify(token, process.env.ACCESS_TOKEN_SECRET!) as { userId: string } 15 | 16 | if (roles.length === 0) { 17 | // if `@Authorized() roles empty`, check is user exist 18 | // return !!context.req.session.userId - session 19 | return !!payload.userId 20 | } 21 | 22 | // if there are some roles defined now in [resolvers, field] 23 | // if (!context.req.session.userId) { - session 24 | if (typeof payload.userId === 'undefined') { 25 | // and if no user logged in, restrict access 26 | return false 27 | } 28 | 29 | // get the user 30 | // const user = await User.findOne(req.session.userId) - session 31 | const user = await User.findOne(payload.userId) 32 | 33 | if (!user) { 34 | throw new Error('User not found') 35 | } 36 | 37 | // and check his permission in db against `roles` argument 38 | const matchRoles = user.roles.filter((roleUserHave) => roles.includes(roleUserHave)) 39 | 40 | if (!matchRoles.length) { 41 | throw new Error(`You do not have sufficient permissions: ${roles}, You Have: ${user.roles}`) 42 | } 43 | 44 | return true 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/authToken.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entity/User' 2 | import { sign } from 'jsonwebtoken' 3 | 4 | export const createAccessToken = (user: User) => { 5 | return sign({ userId: user.id, tokenVersion: user.tokenVersion }, process.env.ACCESS_TOKEN_SECRET!, { 6 | expiresIn: '15m', 7 | }) 8 | } 9 | 10 | export const createRefreshToken = (user: User) => { 11 | return sign({ userId: user.id, tokenVersion: user.tokenVersion }, process.env.REFRESH_TOKEN_SECRET!, { 12 | expiresIn: '7d', 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/createConfirmationUrl.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { redis } from '../redis' 3 | import { confirmUserPrefix } from '../constants' 4 | 5 | export const createConfirmationUrl = async (userId: number) => { 6 | const token = uuid() 7 | // INFO: set redis key:value pair, value is going to be the person who will need to confirm 8 | await redis.set(confirmUserPrefix + token, userId, 'ex', 60 * 60 * 24) // 1 day expiration 9 | 10 | // URL will redirect back to frontend, frontend will call a mutation 11 | return `http://localhost:3000/user-confirm/${token}` 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/createSchema.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'type-graphql' 2 | import { FileUploadResolver } from '../modules/user/FileUpload' 3 | import { authChecker } from './authChecker' 4 | import { AdminResolver } from '../modules/admin/Admin' 5 | import { ChangePasswordResolver } from '../modules/user/ChangePassword' 6 | import { MeResolver } from '../modules/user/Me' 7 | import { LogoutResolver } from '../modules/user/Logout' 8 | import { LoginResolver } from '../modules/user/Login' 9 | import { ForgotPasswordResolver } from '../modules/user/ForgotPassword' 10 | import { ConfirmUserResolver } from '../modules/user/ConfirmUser' 11 | import { RegisterResolver } from '../modules/user/Register' 12 | import { HelloResolver } from '../modules/hello/Hello' 13 | 14 | // Create Schema 15 | export const createSchema = () => 16 | buildSchema({ 17 | // resolvers: [__dirname + '/../modules/*/*.ts'], 18 | resolvers: [ 19 | RegisterResolver, 20 | ConfirmUserResolver, 21 | ForgotPasswordResolver, 22 | LoginResolver, 23 | ChangePasswordResolver, 24 | LogoutResolver, 25 | MeResolver, 26 | AdminResolver, 27 | FileUploadResolver, 28 | HelloResolver, 29 | ], 30 | // validate: true, 31 | authChecker, 32 | }) 33 | -------------------------------------------------------------------------------- /src/utils/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions' 2 | 3 | // Will be true on deployed functions 4 | const prod = process.env.NODE_ENV === 'production' 5 | 6 | const rootDir = process.env.NODE_ENV === 'production' ? 'build' : 'src' 7 | 8 | const typeOrmConfig: PostgresConnectionOptions = { 9 | type: 'postgres', 10 | host: process.env.TYPEORM_HOST, 11 | port: 5432, 12 | username: process.env.TYPEORM_USERNAME, 13 | password: process.env.TYPEORM_PASSWORD, 14 | database: process.env.TYPEORM_DATABASE, 15 | synchronize: true, // tell typeOrm to create the tables if they don't exist. Do not use in Production 16 | // dropSchema: true, // drops the schema each time connection is being established. Debug and Development only. Do not use in Production 17 | logging: true, 18 | entities: [rootDir + '/entity/**/*.{js,ts}'], 19 | migrations: [rootDir + '/migration/**/*.{js,ts}'], 20 | subscribers: [rootDir + '/subscriber/**/*.{js,ts}'], 21 | cli: { 22 | entitiesDir: 'entity', 23 | migrationsDir: 'migration', 24 | subscribersDir: 'subscriber', 25 | }, 26 | // Production Mode 27 | ...(prod && { 28 | logging: false, 29 | // synchronize: false, 30 | }), 31 | } 32 | 33 | export { typeOrmConfig, prod } 34 | -------------------------------------------------------------------------------- /src/utils/passwordValidator.ts: -------------------------------------------------------------------------------- 1 | import { IPasswordConstraint } from '../types/PasswordConstraint' 2 | 3 | export const passwordValidator = ( 4 | constraint: IPasswordConstraint, 5 | value: string, 6 | ): { passed: boolean; message: string | null } => { 7 | // /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[a-zA-Z!#$%&? "])[a-zA-Z0-9!#$%&?]{8,20}$/ 8 | if (constraint.upperCase) { 9 | if (!/^(?=.*[A-Z])/.test(value)) { 10 | return { 11 | passed: false, 12 | message: 'must contain at least one uppercase letter from A-Z', 13 | } 14 | } 15 | } 16 | 17 | if (constraint.lowerCase) { 18 | if (!/^(?=.*[a-z])/.test(value)) { 19 | return { 20 | passed: false, 21 | message: 'must contain at least one lowercase letter from a-z', 22 | } 23 | } 24 | } 25 | 26 | if (constraint.digit) { 27 | if (!/^(?=.*\d)/.test(value)) { 28 | return { 29 | passed: false, 30 | message: 'must contain at least one digit from 0-9', 31 | } 32 | } 33 | } 34 | 35 | if (constraint.specialCharacter) { 36 | if (!/^(?=.*[!@#$%^&*])/.test(value)) { 37 | return { 38 | passed: false, 39 | message: 'must contain at least one special character', 40 | } 41 | } 42 | } 43 | 44 | return { 45 | passed: true, 46 | message: null, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer' 2 | 3 | // async..await is not allowed in global scope, must use a wrapper 4 | export async function sendEmail( 5 | email: string, 6 | url: string, 7 | ): Promise<{ messageId: string; messageUrl: string | boolean }> { 8 | // Generate test SMTP service account from ethereal.email 9 | // Only needed if you don't have a real mail account for testing 10 | const testAccount = await nodemailer.createTestAccount() 11 | 12 | // create reusable transporter object using the default SMTP transport 13 | const transporter = nodemailer.createTransport({ 14 | host: 'smtp.ethereal.email', 15 | port: 587, 16 | secure: false, // true for 465, false for other ports 17 | auth: { 18 | user: testAccount.user, // generated ethereal user 19 | pass: testAccount.pass, // generated ethereal password 20 | }, 21 | }) 22 | 23 | // send mail with defined transport object 24 | const info = await transporter.sendMail({ 25 | from: '"Fred Foo 👻" ', // sender address 26 | to: email, // 'bar@example.com, baz@example.com', // list of receivers 27 | subject: 'Hello ✔', // Subject line 28 | text: 'Hello world?', // plain text body 29 | html: `${url}`, // html body 30 | }) 31 | 32 | // console.log('Message sent: %s', info.messageId) 33 | // Message sent: 34 | 35 | // Preview only available when sending through an Ethereal account 36 | // console.log('Preview URL: %s', nodemailer.getTestMessageUrl(info)) 37 | // Preview URL: https://ethereal.email/message/WaQKMgKddxQDoou... 38 | 39 | return { 40 | messageId: info.messageId, 41 | messageUrl: nodemailer.getTestMessageUrl(info), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/sendRefreshToken.ts: -------------------------------------------------------------------------------- 1 | // res.cookie('jwt', token, { 2 | // httpOnly: true, // so it can't be accessed by javascript xss attacks 3 | // secure: true, //on HTTPS 4 | // domain: 'example.com', // set your domain, if you want it to work w/ sub domain put '.example.com' 5 | // `sub domain i.e api.example.com, www.example.com` - this is important if you're using using SSR (Next.js/Nuxt.js) 6 | // }) 7 | 8 | export const sendRefreshToken = (res: any, token: string) => { 9 | res.cookie('jid', token, { 10 | expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // match the expiration date use in the refresh token (7days) 11 | httpOnly: true, 12 | path: '/refresh_token', 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "src/modules/**/*.+(spec|test).ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["src/**/*.ts", "src/modules/**/*.+(spec|test).ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "moduleResolution": "node", 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": true, 20 | "esModuleInterop": true, 21 | "emitDecoratorMetadata": true, 22 | "experimentalDecorators": true, 23 | "resolveJsonModule": true, 24 | "baseUrl": "." 25 | }, 26 | "exclude": ["node_modules"], 27 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 28 | } 29 | --------------------------------------------------------------------------------