├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── automerge.yml │ └── tests.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .prettierrc.js ├── .yarn └── releases │ └── yarn-1.19.1.cjs ├── .yarnrc ├── README.md ├── jest.config.ts ├── package.json ├── packages ├── babel │ ├── index.js │ └── package.json ├── server │ ├── .env.example │ ├── .gitignore │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── scripts │ │ └── generateSchema.ts │ ├── src │ │ ├── @types │ │ │ └── node.d.ts │ │ ├── app.ts │ │ ├── auth.ts │ │ ├── database.ts │ │ ├── environment.ts │ │ ├── getContext.ts │ │ ├── index.ts │ │ ├── modules │ │ │ ├── common │ │ │ │ └── queries │ │ │ │ │ ├── index.ts │ │ │ │ │ └── version.ts │ │ │ ├── community │ │ │ │ ├── CommunityLoader.ts │ │ │ │ ├── CommunityModel.ts │ │ │ │ ├── CommunityType.ts │ │ │ │ ├── __tests__ │ │ │ │ │ ├── communityCreate.test.ts │ │ │ │ │ ├── communityExit.test.ts │ │ │ │ │ └── communityJoin.test.ts │ │ │ │ ├── fixtures │ │ │ │ │ ├── addUserToCommunity.ts │ │ │ │ │ └── createCommunity.ts │ │ │ │ └── mutations │ │ │ │ │ ├── communityCreateMutation.ts │ │ │ │ │ ├── communityExitAsAdminMutation.ts │ │ │ │ │ ├── communityExitMutation.ts │ │ │ │ │ ├── communityJoinMutation.ts │ │ │ │ │ └── index.ts │ │ │ ├── graphql │ │ │ │ ├── loaderRegister.ts │ │ │ │ ├── typeRegister.ts │ │ │ │ └── types.ts │ │ │ └── user │ │ │ │ ├── UserLoader.ts │ │ │ │ ├── UserModel.ts │ │ │ │ ├── UserType.ts │ │ │ │ ├── __tests__ │ │ │ │ ├── createUser.test.ts │ │ │ │ ├── getUser.test.ts │ │ │ │ └── loginUser.test.ts │ │ │ │ ├── fixtures │ │ │ │ └── createUser.ts │ │ │ │ ├── mutations │ │ │ │ ├── index.ts │ │ │ │ ├── userLoginMutation.ts │ │ │ │ └── userRegisterMutation.ts │ │ │ │ └── queries │ │ │ │ ├── index.ts │ │ │ │ └── userMeQuery.ts │ │ └── schema │ │ │ ├── MutationType.ts │ │ │ ├── QueryType.ts │ │ │ └── schema.ts │ ├── test │ │ ├── babel-transformer.js │ │ ├── clearDatabase.ts │ │ ├── connectWithMongoose.ts │ │ ├── counters.ts │ │ ├── disconnectWithMongoose.ts │ │ ├── environment │ │ │ └── mongodb.js │ │ ├── index.ts │ │ ├── jest.setup.js │ │ └── upsertModel.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── webpack │ │ └── ReloadServerPlugin.js ├── types │ ├── package.json │ ├── src │ │ ├── DeepPartial.ts │ │ ├── Maybe.ts │ │ └── index.ts │ └── tsconfig.json ├── ui │ ├── package.json │ ├── src │ │ ├── Button.tsx │ │ ├── ErrorText.tsx │ │ ├── VStack.tsx │ │ ├── index.ts │ │ └── theme │ │ │ └── index.ts │ └── tsconfig.json └── web │ ├── .env.example │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── public │ └── index.html │ ├── relay.config.js │ ├── src │ ├── @types │ │ └── node.d.ts │ ├── App.tsx │ ├── Providers.tsx │ ├── Routes.tsx │ ├── config.ts │ ├── index.tsx │ ├── modules │ │ ├── auth │ │ │ ├── AuthContext.tsx │ │ │ ├── RequireAuthLayout.tsx │ │ │ ├── security.ts │ │ │ └── useAuth.tsx │ │ ├── feed │ │ │ └── FeedPage.tsx │ │ └── users │ │ │ ├── LoginLayout.tsx │ │ │ ├── LoginPage.tsx │ │ │ ├── LoginRoutes.tsx │ │ │ ├── SignupPage.tsx │ │ │ ├── UserLoginMutation.ts │ │ │ ├── UserRegisterMutation.ts │ │ │ └── __tests__ │ │ │ ├── LoginPage.test.tsx │ │ │ └── SignupPage.test.tsx │ ├── relay │ │ ├── RelayEnvironment.ts │ │ └── fetchGraphQL.ts │ └── shared-components │ │ ├── InputField.tsx │ │ └── Link.tsx │ ├── test │ ├── TestRouter.tsx │ ├── WithProviders.tsx │ ├── babel-transformer.js │ └── jest.setup.js │ ├── tsconfig.json │ └── webpack.config.js ├── tsconfig.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/webpack/ 3 | **/test/ 4 | **/build/ 5 | **/dist/ 6 | 7 | **/webpack.config*.js 8 | **/jest.*.js 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | jest: true, 6 | es6: true, 7 | }, 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | ecmaVersion: 10, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | module: true, 14 | jsx: true, 15 | }, 16 | }, 17 | plugins: [ 18 | '@typescript-eslint', 19 | 'import', 20 | 'prettier', 21 | 'react', 22 | 'react-hooks', 23 | 'relay', 24 | ], 25 | extends: [ 26 | 'prettier', 27 | 'eslint:recommended', 28 | 'plugin:react/jsx-runtime', 29 | 'plugin:relay/recommended', 30 | 'plugin:import/recommended', 31 | 'plugin:@typescript-eslint/eslint-recommended', 32 | 'plugin:@typescript-eslint/recommended', 33 | ], 34 | rules: { 35 | 'prettier/prettier': 'error', 36 | 'no-console': 'error', 37 | 'comma-dangle': ['error', 'always-multiline'], 38 | 'import/first': 'error', 39 | 'import/no-duplicates': 'error', 40 | 'import/named': 'off', 41 | '@typescript-eslint/no-var-requires': 'error', 42 | '@typescript-eslint/no-unused-vars': [ 43 | 'error', 44 | { ignoreRestSiblings: true }, 45 | ], 46 | '@typescript-eslint/no-empty-function': 'error', 47 | '@typescript-eslint/explicit-module-boundary-types': 'off', 48 | 'react/prop-types': 'off', 49 | 'react/display-name': 'off', 50 | 'react/no-deprecated': 'warn', 51 | 'react/self-closing-comp': 'error', 52 | 'react/jsx-child-element-spacing': 'error', 53 | 'react/jsx-closing-tag-location': 'error', 54 | 'react/jsx-boolean-value': ['error', 'never'], 55 | 'react-hooks/rules-of-hooks': 'error', 56 | 'react-hooks/exhaustive-deps': 'error', 57 | 'relay/graphql-syntax': 'error', 58 | 'relay/compat-uses-vars': 'warn', 59 | 'relay/graphql-naming': 'error', 60 | 'relay/generated-flow-types': 'warn', 61 | 'relay/no-future-added-value': 'warn', 62 | 'relay/unused-fields': 'off', 63 | }, 64 | settings: { 65 | react: { 66 | version: 'detect', 67 | }, 68 | 'import/resolver': { 69 | typescript: { 70 | project: ['packages/*/tsconfig.json'], 71 | }, 72 | }, 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '02:00' 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 12 | with: 13 | github-token: ${{ secrets.AUTOMERGE_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: 3 | pull_request: 4 | branches-ignore: 5 | - 'dependabot/*' 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Set up Node v14 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 14.x 18 | 19 | - name: Use cached node_modules 20 | uses: actions/cache@v1 21 | with: 22 | path: node_modules 23 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | nodeModules- 26 | 27 | - name: Installing dependencies... 28 | run: yarn install --frozen-lockfile 29 | env: 30 | CI: true 31 | 32 | - name: Generating the GraphQL Schema... 33 | run: yarn workspace @fakeddit/server schema:generate 34 | 35 | - name: Generating the Relay types... 36 | run: yarn workspace @fakeddit/web relay 37 | 38 | - name: Running ESLint... 39 | run: yarn eslint --fix . 40 | env: 41 | CI: true 42 | 43 | - name: Running Prettier... 44 | run: yarn prettier --write **/src/**/* 45 | env: 46 | CI: true 47 | 48 | - name: Running tests... 49 | run: yarn test:ci 50 | env: 51 | CI: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .vscode/ 4 | tmp/ 5 | *.log 6 | 7 | **/node_modules/ 8 | build/ 9 | dist/ 10 | npm-debug.log* 11 | 12 | yarn-*.log 13 | *.tsbuildinfo 14 | 15 | # Env files 16 | .env* 17 | !.env.example 18 | 19 | # Random things 20 | package-lock.json 21 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,tsx}": [ 3 | "prettier --write", 4 | "eslint --fix" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 80, 7 | jsxSingleQuote: false, 8 | arrowParens: 'avoid', 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | yarn-path ".yarn/releases/yarn-1.19.1.cjs" 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fakeddit - An @entria challenge 2 | 3 | > **:warning: ATTENTION** 4 | > 5 | > Some scripts of each project have been written based on \*nix commands (cp, mv, 6 | > etc), so if you are running this project on a Windows-based system, you can have 7 | > some problems running it. It'll fix soon. Feel free to create an issue if you find 8 | > or a PR that fixes this problem! 9 | 10 | ## How to run 11 | 12 | ### Developer environment 13 | 14 | #### Setup Docker + MongoDB 15 | 16 | ``` 17 | docker run -d -p 27017:27017 --name fakeddit-mongo -d mongo:latest 18 | ``` 19 | 20 | #### Copying the environment variables 21 | 22 | ``` 23 | yarn copy-envs 24 | ``` 25 | 26 | And fill the environment variables on `@fakeddit/server` and `@fakeddit/web` 27 | with the values. 28 | 29 | #### Install the dependencies 30 | First of all, install the dependencies. 31 | ``` 32 | yarn install 33 | ``` 34 | 35 | #### Running server 36 | 37 | Generate the `schema.graphql` file running: 38 | 39 | ``` 40 | yarn workspace @fakeddit/server schema:generate 41 | ``` 42 | 43 | Now, run the server: 44 | 45 | ``` 46 | yarn workspace @fakeddit/server start:dev 47 | ``` 48 | 49 | #### Running the web app 50 | 51 | First of all, generate the types (artifacts) from `relay-compiler`: 52 | 53 | ``` 54 | yarn workspace @fakeddit/web relay 55 | ``` 56 | 57 | And now, you can run the web project: 58 | 59 | ``` 60 | yarn workspace @fakeddit/web start:dev 61 | ``` 62 | 63 | ### Running packages together 64 | 65 | If you already do the necessary setup on the packages, you can run the packages 66 | concurrently with just only one command: 67 | 68 | ``` 69 | yarn dev:all 70 | ``` 71 | 72 | To run the project as dev mode. 73 | 74 | ## References 75 | 76 | - [Relay Realworld](https://github.com/sibelius/relay-realworld) 77 | - [RBAF GraphQL API](https://github.com/daniloab/rbaf-graphql-api) 78 | - [Dev Su](https://github.com/Tsugami/dev-su) 79 | - [Fullstack Playground](https://github.com/daniloab/fullstack-playground) 80 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ['/packages/**/jest.config.js'], 3 | moduleFileExtensions: ['js', 'ts', 'tsx'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fakeddit", 3 | "version": "0.0.1", 4 | "private": true, 5 | "license": "MIT", 6 | "workspaces": { 7 | "packages": [ 8 | "packages/*" 9 | ] 10 | }, 11 | "scripts": { 12 | "test": "jest --maxWorkers=50%", 13 | "test:watch": "jest --watch --maxWorkers=25%", 14 | "test:ci": "jest --runInBand", 15 | "copy-envs": "yarn workspace @fakeddit/server copy:env && yarn workspace @fakeddit/web copy-env", 16 | "dev": "yarn dev:all", 17 | "dev:all": "concurrently \"yarn dev:server\" \"yarn dev:web\"", 18 | "dev:server": "yarn workspace @fakeddit/server start:dev", 19 | "dev:web": "yarn workspace @fakeddit/web start:dev" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.16.12", 23 | "@babel/preset-env": "^7.16.0", 24 | "@babel/preset-typescript": "^7.16.0", 25 | "@commitlint/cli": "^13.2.1", 26 | "@commitlint/config-conventional": "^13.2.0", 27 | "@types/jest": "^27.0.2", 28 | "@types/node": "^16.10.3", 29 | "@typescript-eslint/eslint-plugin": "^4.33.0", 30 | "@typescript-eslint/parser": "^4.33.0", 31 | "babel-jest": "27.4.2", 32 | "concurrently": "^7.0.0", 33 | "eslint": "^7.32.0", 34 | "eslint-config-prettier": "^8.3.0", 35 | "eslint-import-resolver-typescript": "^2.5.0", 36 | "eslint-plugin-import": "^2.25.4", 37 | "eslint-plugin-jsx-a11y": "^6.5.1", 38 | "eslint-plugin-node": "^11.1.0", 39 | "eslint-plugin-prettier": "^4.0.0", 40 | "eslint-plugin-promise": "^5.1.0", 41 | "eslint-plugin-react": "^7.26.1", 42 | "eslint-plugin-react-hooks": "^4.2.0", 43 | "eslint-plugin-relay": "^1.8.3", 44 | "husky": "^7.0.2", 45 | "jest": "27.4.2", 46 | "lint-staged": "^11.2.3", 47 | "prettier": "^2.5.1", 48 | "typescript": "^4.5.5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/babel/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-typescript', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /packages/babel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fakeddit/babel", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "devDependencies": { 6 | "@babel/cli": "^7.16.0", 7 | "@babel/core": "^7.16.12", 8 | "@babel/node": "^7.16.8", 9 | "@babel/preset-env": "^7.16.0", 10 | "@babel/preset-typescript": "^7.16.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT= 3 | JWT_SECRET= 4 | 5 | # Database 6 | MONGO_URI= 7 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | /graphql 2 | -------------------------------------------------------------------------------- /packages/server/babel.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const config = require('@fakeddit/babel'); 3 | 4 | module.exports = config; 5 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const package = require('./package'); 3 | 4 | module.exports = { 5 | displayName: package.name, 6 | name: package.name, 7 | testEnvironment: '/test/environment/mongodb', 8 | testPathIgnorePatterns: ['/node_modules/', './dist'], 9 | coverageReporters: ['lcov', 'html'], 10 | resetModules: false, 11 | transform: { 12 | '^.+\\.(js|ts|tsx)?$': '/test/babel-transformer', 13 | }, 14 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 15 | moduleFileExtensions: ['ts', 'js', 'tsx', 'json'], 16 | setupFiles: ['/test/jest.setup.js'], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fakeddit/server", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "prebuild": "[ -d ./dist ]; rm -rf ./dist", 8 | "build": "tsc", 9 | "start:prod": "node dist/tsc/src/index.js", 10 | "start:dev": "webpack --watch --progress", 11 | "preschema:generate": "[ -d ./graphql ]; rm -rf ./graphql", 12 | "schema:generate": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts\" ./scripts/generateSchema.ts", 13 | "copy:env": "cp .env.example .env" 14 | }, 15 | "dependencies": { 16 | "@entria/graphql-mongo-helpers": "^1.1.2", 17 | "@koa/cors": "^3.1.0", 18 | "@koa/router": "^10.1.1", 19 | "bcryptjs": "^2.4.3", 20 | "dataloader": "^2.0.0", 21 | "dotenv": "^14.3.2", 22 | "graphql": "^16.2.0", 23 | "graphql-relay": "^0.10.0", 24 | "jsonwebtoken": "^8.5.1", 25 | "koa": "^2.13.3", 26 | "koa-bodyparser": "^4.3.0", 27 | "koa-graphql": "^0.12.0", 28 | "mongoose": "^6.0.10" 29 | }, 30 | "devDependencies": { 31 | "@fakeddit/babel": "^0.0.1", 32 | "@types/bcryptjs": "^2.4.2", 33 | "@types/jsonwebtoken": "^8.5.6", 34 | "@types/koa": "^2.13.4", 35 | "@types/koa-bodyparser": "^4.3.5", 36 | "@types/koa-graphql": "^0.8.7", 37 | "@types/koa__cors": "^3.0.3", 38 | "@types/koa__router": "^8.0.11", 39 | "babel-loader": "^8.2.3", 40 | "mongodb-memory-server-global": "^8.0.0", 41 | "webpack": "^5.67.0", 42 | "webpack-cli": "^4.9.2", 43 | "webpack-node-externals": "^3.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/scripts/generateSchema.ts: -------------------------------------------------------------------------------- 1 | import fsSync from 'fs'; 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import { printSchema } from 'graphql/utilities'; 5 | 6 | import { schema } from '../src/schema/schema'; 7 | 8 | const pwd = process.cwd(); 9 | 10 | const schemaFile = 'schema.graphql'; 11 | 12 | const generateSchema = async () => { 13 | const configs = [{ schema, path: path.join(pwd, './graphql', schemaFile) }]; 14 | 15 | for (const config of configs) { 16 | const dirPath = config.path.split(schemaFile)[0]; 17 | 18 | // TODO: Should I put it on a try/catch? 19 | if (!fsSync.existsSync(dirPath)) { 20 | await fs.mkdir(dirPath); 21 | } 22 | 23 | await fs.writeFile(config.path, printSchema(config.schema)); 24 | } 25 | }; 26 | 27 | generateSchema(); 28 | -------------------------------------------------------------------------------- /packages/server/src/@types/node.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: 'development' | 'production'; 5 | PORT: string; 6 | MONGO_URI: string; 7 | JWT_SECRET: string; 8 | } 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /packages/server/src/app.ts: -------------------------------------------------------------------------------- 1 | import Koa, { Request } from 'koa'; 2 | import Router from '@koa/router'; 3 | import bodyparser from 'koa-bodyparser'; 4 | import { graphqlHTTP, OptionsData } from 'koa-graphql'; 5 | import cors from '@koa/cors'; 6 | 7 | import { schema } from './schema/schema'; 8 | import { config } from './environment'; 9 | import { getUser } from './auth'; 10 | import { getContext } from './getContext'; 11 | 12 | const app = new Koa(); 13 | const router = new Router(); 14 | 15 | const graphQlSettingsPerReq = async (req: Request): Promise => { 16 | const user = await getUser(req.header.authorization); 17 | 18 | return { 19 | graphiql: 20 | config.NODE_ENV !== 'production' 21 | ? { 22 | headerEditorEnabled: true, 23 | shouldPersistHeaders: true, 24 | } 25 | : false, 26 | schema, 27 | pretty: true, 28 | context: getContext({ 29 | user, 30 | }), 31 | customFormatErrorFn: ({ message, locations, stack }) => { 32 | /* eslint-disable no-console */ 33 | console.log(message); 34 | console.log(locations); 35 | console.log(stack); 36 | /* eslint-enable no-console */ 37 | 38 | return { 39 | message, 40 | locations, 41 | stack, 42 | }; 43 | }, 44 | }; 45 | }; 46 | 47 | const graphQlServer = graphqlHTTP(graphQlSettingsPerReq); 48 | router.all('/graphql', graphQlServer); 49 | 50 | app.use(cors()); 51 | app.use(bodyparser()); 52 | app.use(router.routes()).use(router.allowedMethods()); 53 | 54 | export default app; 55 | -------------------------------------------------------------------------------- /packages/server/src/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { Maybe } from '@fakeddit/types'; 4 | 5 | import { UserModel, UserDocument } from './modules/user/UserModel'; 6 | import { config } from './environment'; 7 | 8 | export const getUser = async ( 9 | token: Maybe, 10 | ): Promise> => { 11 | if (!token) return null; 12 | 13 | // TODO: Maybe it should be a crime 14 | [, token] = token.split('JWT '); 15 | 16 | const decodedToken = jwt.verify(token, config.JWT_SECRET) as { id: string }; 17 | 18 | const user = await UserModel.findOne({ _id: decodedToken.id }); 19 | 20 | if (!user) return null; 21 | 22 | return user; 23 | }; 24 | 25 | export const generateJwtToken = (userId: string) => 26 | `JWT ${jwt.sign({ id: userId }, config.JWT_SECRET)}`; 27 | -------------------------------------------------------------------------------- /packages/server/src/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { config } from './environment'; 4 | 5 | export const connectDatabase = async (): Promise => { 6 | /* eslint-disable no-console */ 7 | mongoose.connection 8 | .once('open', () => console.log('Connected with the database!')) 9 | .on('error', err => console.log(err)) 10 | .on('close', () => console.log('Database connection was closed!')); 11 | /* eslint-enable no-console */ 12 | 13 | await mongoose.connect(config.MONGO_URI); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/server/src/environment.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | const { PORT, NODE_ENV, MONGO_URI, JWT_SECRET } = process.env; 6 | 7 | export const config = { 8 | NODE_ENV, 9 | PORT: PORT || 3000, 10 | MONGO_URI, 11 | JWT_SECRET, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/src/getContext.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@fakeddit/types'; 2 | 3 | import { UserDocument } from './modules/user/UserModel'; 4 | import { getAllDataLoaders } from './modules/graphql/loaderRegister'; 5 | import { GraphQLContext } from './modules/graphql/types'; 6 | 7 | type ContextVars = { 8 | user?: Maybe; 9 | }; 10 | 11 | export const getContext = (ctx?: ContextVars): GraphQLContext => { 12 | const dataloaders = getAllDataLoaders(); 13 | 14 | return { 15 | dataloaders, 16 | user: ctx?.user || null, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | import { config } from './environment'; 4 | import { connectDatabase } from './database'; 5 | import app from './app'; 6 | 7 | const bootstrap = async () => { 8 | try { 9 | await connectDatabase(); 10 | } catch (err) { 11 | // eslint-disable-next-line no-console 12 | console.error('Unable to connect to database!', err); 13 | process.exit(1); 14 | } 15 | 16 | const server = createServer(app.callback()); 17 | 18 | server.listen(config.PORT, () => { 19 | // eslint-disable-next-line no-console 20 | console.log(`Running at ${config.PORT} port...`); 21 | }); 22 | }; 23 | 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/common/queries/index.ts: -------------------------------------------------------------------------------- 1 | export { version } from './version'; 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/common/queries/version.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull, GraphQLFieldConfig } from 'graphql'; 2 | 3 | import { version as packageVersion } from '../../../../package.json'; 4 | 5 | export const version: GraphQLFieldConfig = { 6 | type: new GraphQLNonNull(GraphQLString), 7 | resolve: () => packageVersion, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/CommunityLoader.ts: -------------------------------------------------------------------------------- 1 | import { createLoader, NullConnection } from '@entria/graphql-mongo-helpers'; 2 | 3 | import { registerLoader } from '../graphql/loaderRegister'; 4 | 5 | import { CommunityModel } from './CommunityModel'; 6 | 7 | const Loader = createLoader({ 8 | model: CommunityModel, 9 | loaderName: 'CommunityLoader', 10 | shouldValidateContextUser: true, 11 | viewerCanSee: (context, data) => (context?.user ? data : NullConnection), 12 | }); 13 | 14 | export default Loader; 15 | export const { Wrapper: User, getLoader, clearCache, load, loadAll } = Loader; 16 | 17 | registerLoader('CommunityLoader', getLoader); 18 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/CommunityModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Types, Document } from 'mongoose'; 2 | 3 | export interface Community { 4 | name: string; 5 | displayName: string; 6 | admin: Types.ObjectId; 7 | members: Types.ObjectId[]; 8 | mods: Types.ObjectId[]; 9 | } 10 | 11 | // TODO: Is there a way to do it better? 12 | export interface CommunityDocument extends Community, Document {} 13 | 14 | const CommunitySchema = new Schema( 15 | { 16 | name: { 17 | type: String, 18 | required: true, 19 | unique: true, 20 | maxlength: 21, 21 | }, 22 | admin: { 23 | type: Schema.Types.ObjectId, 24 | ref: 'User', 25 | required: true, 26 | }, 27 | displayName: { 28 | type: String, 29 | required: true, 30 | }, 31 | mods: { 32 | type: [Schema.Types.ObjectId], 33 | ref: 'User', 34 | default: [], 35 | }, 36 | members: { 37 | type: [Schema.Types.ObjectId], 38 | ref: 'User', 39 | required: true, 40 | }, 41 | }, 42 | { 43 | timestamps: { 44 | createdAt: true, 45 | updatedAt: true, 46 | }, 47 | }, 48 | ); 49 | 50 | export const CommunityModel = mongoose.model( 51 | 'Community', 52 | CommunitySchema, 53 | ); 54 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/CommunityType.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLString, 4 | GraphQLList, 5 | GraphQLID, 6 | GraphQLNonNull, 7 | } from 'graphql'; 8 | import { globalIdField } from 'graphql-relay'; 9 | import { 10 | withFilter, 11 | connectionArgs, 12 | connectionDefinitions, 13 | } from '@entria/graphql-mongo-helpers'; 14 | 15 | import { UserConnection } from '../user/UserType'; 16 | import UserLoader from '../user/UserLoader'; 17 | 18 | import { nodeInterface, registerTypeLoader } from '../graphql/typeRegister'; 19 | 20 | import { CommunityDocument } from './CommunityModel'; 21 | import { load } from './CommunityLoader'; 22 | 23 | export const CommunityType = new GraphQLObjectType({ 24 | name: 'Community', 25 | fields: () => ({ 26 | id: globalIdField('Community'), 27 | name: { 28 | type: new GraphQLNonNull(GraphQLString), 29 | resolve: community => community.name, 30 | description: 'The slugged name of the community - this is unique', 31 | }, 32 | displayName: { 33 | type: new GraphQLNonNull(GraphQLString), 34 | resolve: community => community.displayName, 35 | description: 36 | "Some custom name that doens't necessary be the name of the community", 37 | }, 38 | admin: { 39 | type: new GraphQLNonNull(GraphQLID), 40 | }, 41 | // TODO: It should be a connection pagination 42 | mods: { 43 | type: new GraphQLNonNull(new GraphQLList(GraphQLID)), 44 | resolve: community => community.mods, 45 | }, 46 | members: { 47 | type: new GraphQLNonNull(UserConnection.connectionType), 48 | args: { ...connectionArgs }, 49 | resolve: async (community, args, context) => 50 | await UserLoader.loadAll( 51 | context, 52 | withFilter(args, { communities: community._id }), 53 | ), 54 | description: 55 | 'A list containing the IDs of all users that is member of this community', 56 | }, 57 | }), 58 | interfaces: () => [nodeInterface], 59 | }); 60 | 61 | export const CommunityConnection = connectionDefinitions({ 62 | name: 'CommunityConnectioon', 63 | nodeType: CommunityType, 64 | }); 65 | 66 | registerTypeLoader(CommunityType, load); 67 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/__tests__/communityCreate.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { fromGlobalId } from 'graphql-relay'; 3 | 4 | import { 5 | clearDatabaseAndRestartCounters, 6 | connectWithMongoose, 7 | disconnectWithMongoose, 8 | } from '../../../../test'; 9 | import { schema } from '../../../schema/schema'; 10 | import { getContext } from '../../../getContext'; 11 | 12 | import { createUser } from '../../user/fixtures/createUser'; 13 | 14 | beforeAll(connectWithMongoose); 15 | beforeEach(clearDatabaseAndRestartCounters); 16 | afterAll(disconnectWithMongoose); 17 | 18 | it('should create a new community', async () => { 19 | const user = await createUser(); 20 | 21 | const mutation = ` 22 | mutation M($displayName: String!, $communityId: String!) { 23 | communityCreate( 24 | input: { displayName: $displayName, communityId: $communityId } 25 | ) { 26 | community { 27 | id 28 | name 29 | displayName 30 | members(first: 10) { 31 | edges { 32 | node { 33 | id 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | `; 41 | 42 | const variableValues = { 43 | displayName: 'A community to lovers of tests', 44 | communityId: 'WeLoveTests', 45 | }; 46 | 47 | const result = await graphql({ 48 | schema, 49 | source: mutation, 50 | contextValue: getContext({ user }), 51 | variableValues, 52 | }); 53 | 54 | expect(result.errors).toBeUndefined(); 55 | 56 | // TODO: Remove this @ts-ignore fixing the type 57 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 58 | // @ts-ignore 59 | const { community } = result?.data?.communityCreate; 60 | 61 | expect(community.id).toBeDefined(); 62 | expect(community.name).toBe(variableValues.communityId); 63 | expect(community.displayName).toBe(variableValues.displayName); 64 | expect(community.members.edges).toHaveLength(1); 65 | 66 | const membersId = community.members.edges.map( 67 | edge => fromGlobalId(edge.node.id).id, 68 | ); 69 | 70 | expect(membersId).toContain(user._id.toString()); 71 | }); 72 | 73 | it("should not allow create a community if doesn't have authorization header", async () => { 74 | const mutation = ` 75 | mutation M($displayName: String!, $communityId: String!) { 76 | communityCreate( 77 | input: { displayName: $displayName, communityId: $communityId } 78 | ) { 79 | community { 80 | id 81 | } 82 | } 83 | } 84 | `; 85 | 86 | const variableValues = { 87 | displayName: 'A community to lovers of tests', 88 | communityId: 'WeLoveTests', 89 | }; 90 | 91 | const result = await graphql({ schema, source: mutation, variableValues }); 92 | 93 | expect(result?.data?.communityCreate).toBeNull(); 94 | 95 | expect(result?.errors).toBeDefined(); 96 | expect(result.errors && result.errors[0]?.message).toBe( 97 | 'You are not logged in. Please, try again!', 98 | ); 99 | }); 100 | 101 | it('should not create a duplicate community', async () => { 102 | const user = await createUser(); 103 | 104 | const mutation = ` 105 | mutation M($displayName: String!, $communityId: String!) { 106 | communityCreate( 107 | input: { displayName: $displayName, communityId: $communityId } 108 | ) { 109 | community { 110 | id 111 | } 112 | } 113 | } 114 | `; 115 | 116 | const variableValues = { 117 | displayName: 'A community to lovers of tests', 118 | communityId: 'WeLoveTests', 119 | }; 120 | 121 | const contextValue = getContext({ user }); 122 | 123 | await graphql({ 124 | schema, 125 | source: mutation, 126 | contextValue, 127 | variableValues, 128 | }); 129 | 130 | const result = await graphql({ 131 | schema, 132 | source: mutation, 133 | contextValue, 134 | variableValues, 135 | }); 136 | 137 | expect(result?.data?.communityCreate).toBeNull(); 138 | 139 | expect(result?.errors).toBeDefined(); 140 | expect(result.errors && result.errors[0].message).toBe( 141 | 'A community with this name has already been created. Please, try again!', 142 | ); 143 | }); 144 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/__tests__/communityExit.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { fromGlobalId } from 'graphql-relay'; 3 | 4 | import { 5 | clearDatabaseAndRestartCounters, 6 | connectWithMongoose, 7 | disconnectWithMongoose, 8 | } from '../../../../test'; 9 | import { schema } from '../../../schema/schema'; 10 | import { getContext } from '../../../getContext'; 11 | 12 | import { createUser } from '../../user/fixtures/createUser'; 13 | 14 | import { createCommunity } from '../fixtures/createCommunity'; 15 | import { addUserToCommunity } from '../fixtures/addUserToCommunity'; 16 | 17 | beforeAll(connectWithMongoose); 18 | beforeEach(clearDatabaseAndRestartCounters); 19 | afterAll(disconnectWithMongoose); 20 | 21 | it('should exit a community', async () => { 22 | const createdCommunity = await createCommunity({ name: 'WeLoveTests' }); 23 | const user = await createUser(); 24 | 25 | await addUserToCommunity(user, createdCommunity); 26 | 27 | const mutation = ` 28 | mutation M($communityName: String!) { 29 | communityExit(input: {communityName: $communityName}) { 30 | community { 31 | id 32 | members { 33 | edges { 34 | node { 35 | id 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | `; 43 | 44 | const variableValues = { 45 | communityName: 'WeLoveTests', 46 | }; 47 | 48 | const contextValue = getContext({ user }); 49 | 50 | const result = await graphql({ 51 | schema, 52 | source: mutation, 53 | contextValue, 54 | variableValues, 55 | }); 56 | 57 | expect(result.errors).toBeUndefined(); 58 | 59 | await graphql({ 60 | schema, 61 | source: mutation, 62 | contextValue, 63 | variableValues, 64 | }); 65 | 66 | // TODO: Remove this @ts-ignore fixing the type 67 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 68 | // @ts-ignore 69 | const { community } = result?.data?.communityExit; 70 | 71 | expect(community.members.edges).toHaveLength(1); 72 | 73 | const membersId = community.members.edges.map( 74 | edge => fromGlobalId(edge.node.id).id, 75 | ); 76 | 77 | expect(membersId).not.toContain(user._id.toString()); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/__tests__/communityJoin.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { fromGlobalId } from 'graphql-relay'; 3 | 4 | import { 5 | clearDatabaseAndRestartCounters, 6 | connectWithMongoose, 7 | disconnectWithMongoose, 8 | } from '../../../../test'; 9 | import { schema } from '../../../schema/schema'; 10 | import { getContext } from '../../../getContext'; 11 | 12 | import { createUser } from '../../user/fixtures/createUser'; 13 | 14 | import { createCommunity } from '../fixtures/createCommunity'; 15 | 16 | beforeAll(connectWithMongoose); 17 | beforeEach(clearDatabaseAndRestartCounters); 18 | afterAll(disconnectWithMongoose); 19 | 20 | it('should join a created community', async () => { 21 | await createCommunity({ name: 'WeLoveTests' }); 22 | const user = await createUser(); 23 | 24 | const mutation = ` 25 | mutation M($communityId: String!) { 26 | communityJoin(input: { communityId: $communityId }) { 27 | community { 28 | id 29 | members(first: 10) { 30 | edges { 31 | node { 32 | id 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | `; 40 | 41 | const variableValues = { 42 | communityId: 'WeLoveTests', 43 | }; 44 | 45 | const contextValue = getContext({ user }); 46 | 47 | const result = await graphql({ 48 | schema, 49 | source: mutation, 50 | contextValue, 51 | variableValues, 52 | }); 53 | 54 | expect(result.errors).toBeUndefined(); 55 | 56 | // TODO: Remove this @ts-ignore fixing the type 57 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 58 | // @ts-ignore 59 | const { community } = result?.data?.communityJoin; 60 | 61 | expect(community.members.edges).toHaveLength(2); 62 | 63 | const membersId = community.members.edges.map( 64 | edge => fromGlobalId(edge.node.id).id, 65 | ); 66 | 67 | expect(membersId).toContain(user._id.toString()); 68 | }); 69 | 70 | it("should not allow to join a community if doesn't have authorization header", async () => { 71 | const mutation = ` 72 | mutation M($communityId: String!) { 73 | communityJoin(input: { communityId: $communityId }) { 74 | community { 75 | id 76 | } 77 | } 78 | } 79 | `; 80 | 81 | const variableValues = { 82 | communityId: 'WeLoveTests', 83 | }; 84 | 85 | const contextValue = getContext(); 86 | 87 | const result = await graphql({ 88 | schema, 89 | source: mutation, 90 | contextValue, 91 | variableValues, 92 | }); 93 | 94 | expect(result?.data?.communityJoin).toBeNull(); 95 | 96 | expect(result?.errors).toBeDefined(); 97 | expect(result.errors && result.errors[0].message).toBe( 98 | 'You are not logged in. Please, try again!', 99 | ); 100 | }); 101 | 102 | it('should not join a non-existent community', async () => { 103 | const user = await createUser(); 104 | 105 | const mutation = ` 106 | mutation M($communityId: String!) { 107 | communityJoin(input: { communityId: $communityId }) { 108 | community { 109 | id 110 | } 111 | } 112 | } 113 | `; 114 | 115 | const variableValues = { 116 | communityId: 'WeLoveTestsButThisCommunityDoesntExist', 117 | }; 118 | 119 | const contextValue = getContext({ user }); 120 | 121 | await graphql({ 122 | schema, 123 | source: mutation, 124 | contextValue, 125 | variableValues, 126 | }); 127 | 128 | const result = await graphql({ 129 | schema, 130 | source: mutation, 131 | contextValue, 132 | variableValues, 133 | }); 134 | 135 | expect(result?.data?.communityJoin).toBeNull(); 136 | 137 | expect(result?.errors).toBeDefined(); 138 | expect(result.errors && result.errors[0].message).toBe( 139 | "This community doesn't exist. Please, try again.", 140 | ); 141 | }); 142 | 143 | it('should not join a community that you already is a member', async () => { 144 | await createCommunity({ name: 'WeLoveTests' }); 145 | const user = await createUser(); 146 | 147 | const mutation = ` 148 | mutation M($communityId: String!) { 149 | communityJoin(input: { communityId: $communityId }) { 150 | community { 151 | id 152 | } 153 | } 154 | } 155 | `; 156 | 157 | const variableValues = { 158 | communityId: 'WeLoveTests', 159 | }; 160 | 161 | const contextValue = getContext({ user }); 162 | 163 | await graphql({ 164 | schema, 165 | source: mutation, 166 | contextValue, 167 | variableValues, 168 | }); 169 | 170 | const result = await graphql({ 171 | schema, 172 | source: mutation, 173 | contextValue, 174 | variableValues, 175 | }); 176 | 177 | expect(result?.data?.communityJoin).toBeNull(); 178 | 179 | expect(result?.errors).toBeDefined(); 180 | expect(result.errors && result.errors[0].message).toBe( 181 | 'You are already a member of this community.', 182 | ); 183 | }); 184 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/fixtures/addUserToCommunity.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from '../../user/UserModel'; 2 | 3 | import { CommunityDocument } from '../CommunityModel'; 4 | 5 | export const addUserToCommunity = async ( 6 | user: UserDocument, 7 | community: CommunityDocument, 8 | ) => { 9 | await user.updateOne({ $addToSet: { communities: community._id } }); 10 | await community.updateOne({ $addToSet: { members: user._id } }); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/fixtures/createCommunity.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from '@fakeddit/types'; 2 | 3 | import { UserModel, UserDocument } from '../../user/UserModel'; 4 | import { createUser } from '../../user/fixtures/createUser'; 5 | 6 | import { getCounter } from '../../../../test/counters'; 7 | import { upsertModel } from '../../../../test/upsertModel'; 8 | 9 | import { CommunityModel, Community } from '../CommunityModel'; 10 | 11 | export const createCommunity = async ( 12 | args?: Omit, 'admin' | 'mods' | 'members'>, 13 | ) => { 14 | const i = getCounter('community'); 15 | 16 | const user = await upsertModel( 17 | UserModel, 18 | createUser, 19 | ); 20 | 21 | const community = await new CommunityModel({ 22 | name: `community#${i}`, 23 | displayName: `MockedCommunity#${i}`, 24 | ...args, 25 | admin: user._id, 26 | members: [user._id], 27 | }).save(); 28 | 29 | await user.updateOne({ $addToSet: { communities: community._id } }); 30 | 31 | return community; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/mutations/communityCreateMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | 4 | import { GraphQLContext } from '../../graphql/types'; 5 | 6 | import { CommunityModel } from '../CommunityModel'; 7 | import { CommunityType } from '../CommunityType'; 8 | 9 | export const communityCreate = mutationWithClientMutationId({ 10 | name: 'CommunityCreate', 11 | inputFields: { 12 | communityId: { type: new GraphQLNonNull(GraphQLString) }, 13 | displayName: { type: new GraphQLNonNull(GraphQLString) }, 14 | }, 15 | mutateAndGetPayload: async ( 16 | { communityId, ...rest }, 17 | ctx: GraphQLContext, 18 | ) => { 19 | if (!ctx?.user) { 20 | throw new Error('You are not logged in. Please, try again!'); 21 | } 22 | 23 | const communityFound = await CommunityModel.findOne({ 24 | name: communityId, 25 | }); 26 | 27 | if (communityFound) { 28 | throw new Error( 29 | 'A community with this name has already been created. Please, try again!', 30 | ); 31 | } 32 | 33 | const community = new CommunityModel({ 34 | ...rest, 35 | name: communityId, 36 | admin: ctx.user, 37 | members: ctx.user, 38 | }); 39 | 40 | await Promise.all([ 41 | community.save(), 42 | ctx.user.updateOne({ 43 | $addToSet: { communities: community._id }, 44 | }), 45 | ]); 46 | 47 | return { 48 | community, 49 | }; 50 | }, 51 | outputFields: () => ({ 52 | community: { 53 | type: CommunityType, 54 | resolve: ({ community }) => community, 55 | }, 56 | }), 57 | }); 58 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/mutations/communityExitAsAdminMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | 4 | import { GraphQLContext } from '../../graphql/types'; 5 | 6 | import { CommunityModel } from '../CommunityModel'; 7 | import { CommunityType } from '../CommunityType'; 8 | 9 | export const communityExitAsAdmin = mutationWithClientMutationId({ 10 | name: 'CommunityExitAsAdmin', 11 | inputFields: { 12 | communityName: { type: new GraphQLNonNull(GraphQLString) }, 13 | }, 14 | mutateAndGetPayload: async ({ communityName }, ctx: GraphQLContext) => { 15 | if (!ctx.user) { 16 | throw new Error('You are not logged in. Please, try again!'); 17 | } 18 | 19 | const foundCommunity = await CommunityModel.findOne({ 20 | name: communityName, 21 | }); 22 | 23 | if (!foundCommunity) { 24 | throw new Error("This community doesn't exist. Please, try again."); 25 | } 26 | 27 | const foundMemberIdInCommuntiy = foundCommunity.members.includes( 28 | ctx.user._id, 29 | ); 30 | const foundCommuntiyIdInUser = ctx.user?.communities.includes( 31 | foundCommunity._id, 32 | ); 33 | 34 | if (!foundMemberIdInCommuntiy || !foundCommuntiyIdInUser) { 35 | throw new Error('You are not a member of this community.'); 36 | } 37 | 38 | if (foundCommunity.mods.length > 0) { 39 | await foundCommunity.updateOne({ 40 | admin: foundCommunity.mods[0]._id, 41 | $pull: { 42 | mods: foundCommunity.mods[0]._id, 43 | members: ctx.user._id, 44 | }, 45 | }); 46 | } else { 47 | await foundCommunity.remove(); 48 | } 49 | 50 | await ctx.user?.updateOne({ $pull: { communities: foundCommunity._id } }); 51 | 52 | return { 53 | userId: ctx.user._id, 54 | communityId: foundCommunity._id, 55 | }; 56 | }, 57 | outputFields: () => ({ 58 | community: { 59 | type: CommunityType, 60 | resolve: async ({ communityId }) => 61 | await CommunityModel.findOne({ _id: communityId }), 62 | }, 63 | }), 64 | }); 65 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/mutations/communityExitMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | 4 | import { GraphQLContext } from '../../graphql/types'; 5 | 6 | import { CommunityModel } from '../CommunityModel'; 7 | import { CommunityType } from '../CommunityType'; 8 | 9 | export const communityExit = mutationWithClientMutationId({ 10 | name: 'CommunityExit', 11 | inputFields: { 12 | communityName: { type: new GraphQLNonNull(GraphQLString) }, 13 | }, 14 | mutateAndGetPayload: async ({ communityName }, ctx: GraphQLContext) => { 15 | if (!ctx.user) { 16 | throw new Error('You are not logged in. Please, try again!'); 17 | } 18 | 19 | const foundCommunity = await CommunityModel.findOne({ 20 | name: communityName, 21 | }); 22 | 23 | if (!foundCommunity) { 24 | throw new Error("This community doesn't exist. Please, try again."); 25 | } 26 | 27 | const foundMemberIdInCommuntiy = foundCommunity.members.includes( 28 | ctx.user._id, 29 | ); 30 | const foundCommuntiyIdInUser = ctx.user.communities.includes( 31 | foundCommunity._id, 32 | ); 33 | 34 | if (!foundMemberIdInCommuntiy || foundCommuntiyIdInUser) { 35 | throw new Error('You are not a member of this community.'); 36 | } 37 | 38 | if (foundCommunity.admin.equals(ctx.user._id)) { 39 | throw new Error( 40 | "You can't exit a community using this method being the admin. Try communityExitAsAdmin mutation!", 41 | ); 42 | } 43 | 44 | await Promise.all([ 45 | foundCommunity.updateOne({ $pull: { members: ctx.user._id } }), 46 | ctx.user.updateOne({ $pull: { communities: foundCommunity?._id } }), 47 | ]); 48 | 49 | return { 50 | userId: ctx.user._id, 51 | communityId: foundCommunity._id, 52 | }; 53 | }, 54 | outputFields: () => ({ 55 | community: { 56 | type: CommunityType, 57 | resolve: async ({ communityId }) => 58 | await CommunityModel.findOne({ _id: communityId }), 59 | }, 60 | }), 61 | }); 62 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/mutations/communityJoinMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | 4 | import { GraphQLContext } from '../../graphql/types'; 5 | 6 | import { CommunityModel } from '../CommunityModel'; 7 | import { CommunityType } from '../CommunityType'; 8 | 9 | export const communityJoin = mutationWithClientMutationId({ 10 | name: 'CommunityJoin', 11 | inputFields: { 12 | communityId: { type: new GraphQLNonNull(GraphQLString) }, 13 | }, 14 | mutateAndGetPayload: async ({ communityId }, ctx: GraphQLContext) => { 15 | if (!ctx.user) { 16 | throw new Error('You are not logged in. Please, try again!'); 17 | } 18 | 19 | const community = await CommunityModel.findOne({ name: communityId }); 20 | 21 | if (!community) { 22 | throw new Error("This community doesn't exist. Please, try again."); 23 | } 24 | 25 | const foundMemberIdInCommuntiy = community.members.includes(ctx.user._id); 26 | const foundCommuntiyIdInUser = ctx.user.communities.includes(community._id); 27 | 28 | if (foundMemberIdInCommuntiy || foundCommuntiyIdInUser) { 29 | throw new Error('You are already a member of this community.'); 30 | } 31 | 32 | await Promise.all([ 33 | community.updateOne({ 34 | $addToSet: { members: [...community.members, ctx.user._id] }, 35 | }), 36 | ctx.user.updateOne({ 37 | $addToSet: { 38 | communities: [...(ctx.user.communities || []), community._id], 39 | }, 40 | }), 41 | ]); 42 | 43 | return { 44 | userId: ctx.user._id, 45 | communityId: community._id, 46 | }; 47 | }, 48 | outputFields: () => ({ 49 | community: { 50 | type: CommunityType, 51 | resolve: async ({ communityId }) => 52 | await CommunityModel.findOne({ _id: communityId }), 53 | }, 54 | }), 55 | }); 56 | -------------------------------------------------------------------------------- /packages/server/src/modules/community/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './communityCreateMutation'; 2 | export * from './communityJoinMutation'; 3 | export * from './communityExitMutation'; 4 | export * from './communityExitAsAdminMutation'; 5 | -------------------------------------------------------------------------------- /packages/server/src/modules/graphql/loaderRegister.ts: -------------------------------------------------------------------------------- 1 | export interface DataLoaders { 2 | UserLoader?: ReturnType; 3 | } 4 | 5 | const loaders: { 6 | [Name in keyof DataLoaders]: () => DataLoaders[Name]; 7 | } = {}; 8 | 9 | const registerLoader = ( 10 | key: Name, 11 | getLoader: typeof loaders[Name], 12 | ) => { 13 | loaders[key] = getLoader; 14 | }; 15 | 16 | const getAllDataLoaders = (): DataLoaders => 17 | Object.entries(loaders).reduce( 18 | (obj, [loaderKey, loaderFn]) => ({ 19 | ...obj, 20 | [loaderKey]: loaderFn(), 21 | }), 22 | {}, 23 | ); 24 | 25 | export { registerLoader, getAllDataLoaders }; 26 | -------------------------------------------------------------------------------- /packages/server/src/modules/graphql/typeRegister.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { fromGlobalId, nodeDefinitions } from 'graphql-relay'; 4 | 5 | import { GraphQLContext } from './types'; 6 | 7 | type Load = (context: GraphQLContext, id: string) => any; 8 | type TypeLoaders = { 9 | [key: string]: { 10 | type: GraphQLObjectType; 11 | load: Load; 12 | }; 13 | }; 14 | 15 | const typesLoaders: TypeLoaders = {}; 16 | 17 | export const { nodeField, nodesField, nodeInterface } = nodeDefinitions( 18 | (globalId, context: GraphQLContext) => { 19 | const { type, id } = fromGlobalId(globalId); 20 | 21 | const { load } = typesLoaders[type] || { load: null }; 22 | 23 | return (load && load(context, id)) || null; 24 | }, 25 | obj => { 26 | const { type } = typesLoaders[obj.constructor.name] || { type: null }; 27 | 28 | return type; 29 | }, 30 | ); 31 | 32 | export const getTypesLoaders = () => typesLoaders; 33 | 34 | export const registerTypeLoader = (type: GraphQLObjectType, load: Load) => { 35 | typesLoaders[type.name] = { 36 | type, 37 | load, 38 | }; 39 | 40 | return type; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/server/src/modules/graphql/types.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@fakeddit/types'; 2 | 3 | import { UserDocument } from '../user/UserModel'; 4 | import { DataLoaders } from '../graphql/loaderRegister'; 5 | 6 | export type GraphQLContext = { 7 | user?: Maybe; 8 | dataloaders?: DataLoaders; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/UserLoader.ts: -------------------------------------------------------------------------------- 1 | import { createLoader, NullConnection } from '@entria/graphql-mongo-helpers'; 2 | 3 | import { registerLoader } from '../graphql/loaderRegister'; 4 | 5 | import { UserModel } from './UserModel'; 6 | 7 | const Loader = createLoader({ 8 | model: UserModel, 9 | loaderName: 'UserLoader', 10 | shouldValidateContextUser: true, 11 | viewerCanSee: (context, data) => (context?.user ? data : NullConnection), 12 | }); 13 | 14 | export default Loader; 15 | export const { Wrapper: User, getLoader, clearCache, load, loadAll } = Loader; 16 | 17 | registerLoader('UserLoader', getLoader); 18 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/UserModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Document, Types } from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | 4 | export interface User { 5 | username: string; 6 | displayName?: string; 7 | email: string; 8 | password: string; 9 | communities: Types.ObjectId[]; 10 | } 11 | 12 | export interface UserDocument extends User, Document { 13 | hashPassword(password: string): Promise; 14 | } 15 | 16 | // TODO: For some reason, I can't pass User interface to generic Schema type 17 | // we should verify why and fix it 18 | const UserSchema = new Schema( 19 | { 20 | username: { 21 | type: String, 22 | required: true, 23 | unique: true, 24 | min: 3, 25 | max: 12, 26 | }, 27 | displayName: { 28 | type: String, 29 | maxlength: 30, 30 | }, 31 | password: { 32 | type: String, 33 | required: true, 34 | }, 35 | email: { 36 | type: String, 37 | required: true, 38 | unique: true, 39 | }, 40 | communities: { 41 | type: [Schema.Types.ObjectId], 42 | default: [], 43 | ref: 'Community', 44 | }, 45 | }, 46 | { 47 | timestamps: { 48 | createdAt: true, 49 | updatedAt: true, 50 | }, 51 | }, 52 | ); 53 | 54 | UserSchema.pre('save', async function (next) { 55 | if (this.isModified('password') || this.isNew) { 56 | const hashedPassword = await this.hashPassword(this.password); 57 | this.password = hashedPassword; 58 | } 59 | 60 | return next(); 61 | }); 62 | 63 | UserSchema.methods = { 64 | hashPassword: async function (password: string) { 65 | const salt = await bcrypt.genSalt(10); 66 | const hashedPassword = await bcrypt.hash(password, salt); 67 | 68 | return hashedPassword; 69 | }, 70 | }; 71 | 72 | export const UserModel = mongoose.model('User', UserSchema); 73 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/UserType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { globalIdField } from 'graphql-relay'; 3 | import { 4 | connectionDefinitions, 5 | connectionArgs, 6 | withFilter, 7 | } from '@entria/graphql-mongo-helpers'; 8 | 9 | import { CommunityConnection } from '../community/CommunityType'; 10 | import CommunityLoader from '../community/CommunityLoader'; 11 | 12 | import { registerTypeLoader, nodeInterface } from '../graphql/typeRegister'; 13 | 14 | import { User } from './UserModel'; 15 | import { load } from './UserLoader'; 16 | 17 | export const UserType = new GraphQLObjectType({ 18 | name: 'User', 19 | fields: () => ({ 20 | id: globalIdField('User'), 21 | username: { 22 | type: new GraphQLNonNull(GraphQLString), 23 | resolve: user => user.username, 24 | }, 25 | displayName: { 26 | type: GraphQLString, 27 | resolve: user => user.displayName, 28 | }, 29 | email: { 30 | type: new GraphQLNonNull(GraphQLString), 31 | resolve: user => user.email, 32 | }, 33 | communities: { 34 | type: new GraphQLNonNull(CommunityConnection.connectionType), 35 | args: { ...connectionArgs }, 36 | resolve: async (user, args, context) => 37 | await CommunityLoader.loadAll( 38 | context, 39 | withFilter(args, { members: user._id }), 40 | ), 41 | }, 42 | }), 43 | interfaces: () => [nodeInterface], 44 | }); 45 | 46 | export const UserConnection = connectionDefinitions({ 47 | name: 'UserConnection', 48 | nodeType: UserType, 49 | }); 50 | 51 | registerTypeLoader(UserType, load); 52 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/__tests__/createUser.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | 3 | import { 4 | clearDatabaseAndRestartCounters, 5 | connectWithMongoose, 6 | disconnectWithMongoose, 7 | } from '../../../../test'; 8 | import { schema } from '../../../schema/schema'; 9 | 10 | beforeAll(connectWithMongoose); 11 | beforeEach(clearDatabaseAndRestartCounters); 12 | afterAll(disconnectWithMongoose); 13 | 14 | it('should create a new user', async () => { 15 | const mutation = ` 16 | mutation M($username: String!, $displayName: String!, $email: String!, $password: String!) { 17 | userRegisterMutation(input: {username: $username, displayName: $displayName, email: $email, password: $password}) { 18 | token 19 | me { 20 | id 21 | username 22 | displayName 23 | email 24 | } 25 | } 26 | } 27 | `; 28 | 29 | const rootValue = {}; 30 | 31 | const variableValues = { 32 | username: 'noghartt', 33 | displayName: 'Noghartt', 34 | email: 'john@doe.com', 35 | password: '123abcAd9=D', 36 | }; 37 | 38 | const result = await graphql({ 39 | schema, 40 | source: mutation, 41 | rootValue, 42 | variableValues, 43 | }); 44 | 45 | expect(result.errors).toBeUndefined(); 46 | 47 | // TODO: Remove this @ts-ignore fixing the type 48 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 49 | // @ts-ignore 50 | const { me, token } = result?.data?.userRegisterMutation; 51 | 52 | expect(token).toBeDefined(); 53 | expect(me.id).toBeDefined(); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/__tests__/getUser.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | 3 | import { 4 | clearDatabaseAndRestartCounters, 5 | connectWithMongoose, 6 | disconnectWithMongoose, 7 | } from '../../../../test'; 8 | import { schema } from '../../../schema/schema'; 9 | import { getContext } from '../../../getContext'; 10 | 11 | import { createUser } from '../fixtures/createUser'; 12 | 13 | beforeAll(connectWithMongoose); 14 | beforeEach(clearDatabaseAndRestartCounters); 15 | afterAll(disconnectWithMongoose); 16 | 17 | it('should get the user', async () => { 18 | const user = await createUser(); 19 | 20 | const query = ` 21 | query me { 22 | me { 23 | id 24 | } 25 | } 26 | `; 27 | 28 | const result = await graphql({ 29 | schema, 30 | source: query, 31 | contextValue: getContext({ user }), 32 | }); 33 | 34 | expect(result.errors).toBeUndefined(); 35 | 36 | // TODO: Remove this @ts-ignore fixing the type 37 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 38 | // @ts-ignore 39 | const { id } = result?.data?.me; 40 | 41 | expect(id).toBeDefined(); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/__tests__/loginUser.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | 3 | import { 4 | clearDatabaseAndRestartCounters, 5 | connectWithMongoose, 6 | disconnectWithMongoose, 7 | } from '../../../../test'; 8 | import { schema } from '../../../schema/schema'; 9 | 10 | import { createUser } from '../fixtures/createUser'; 11 | 12 | beforeAll(connectWithMongoose); 13 | beforeEach(clearDatabaseAndRestartCounters); 14 | afterAll(disconnectWithMongoose); 15 | 16 | it('should login with a registered user', async () => { 17 | const { username } = await createUser({ 18 | username: 'noghartt', 19 | password: '123abcAd9=D', 20 | }); 21 | 22 | const mutation = ` 23 | mutation UserLoginMutation($username: String!, $password: String!) { 24 | userLoginMutation(input: {username: $username, password: $password}) { 25 | token 26 | me { 27 | id 28 | } 29 | } 30 | } 31 | `; 32 | 33 | const variableValues = { 34 | username, 35 | password: '123abcAd9=D', 36 | }; 37 | 38 | const result = await graphql({ 39 | schema, 40 | source: mutation, 41 | variableValues, 42 | }); 43 | 44 | expect(result.errors).toBeUndefined(); 45 | 46 | // TODO: Remove this @ts-ignore fixing the type 47 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 48 | // @ts-ignore 49 | const { token, me } = result?.data?.userLoginMutation; 50 | 51 | expect(me.id).toBeDefined(); 52 | expect(token).toBeDefined(); 53 | }); 54 | 55 | it("should display error if username isn't exists", async () => { 56 | await createUser(); 57 | 58 | const mutation = ` 59 | mutation UserLoginMutation($username: String!, $password: String!) { 60 | userLoginMutation(input: {username: $username, password: $password}) { 61 | token 62 | } 63 | } 64 | `; 65 | 66 | const variableValues = { 67 | username: 'noghartt', 68 | password: '123abcAd9=D', 69 | }; 70 | 71 | const result = await graphql({ 72 | schema, 73 | source: mutation, 74 | variableValues, 75 | }); 76 | 77 | expect(result.data?.userLoginMutation).toBeNull(); 78 | 79 | expect(result.errors).toBeDefined(); 80 | expect(result.errors[0].message).toBe( 81 | 'This user was not registered. Please, try again!', 82 | ); 83 | }); 84 | 85 | it('should display error if password is incorrect', async () => { 86 | await createUser({ username: 'noghartt' }); 87 | 88 | const mutation = ` 89 | mutation UserLoginMutation($username: String!, $password: String!) { 90 | userLoginMutation(input: {username: $username, password: $password}) { 91 | token 92 | } 93 | } 94 | `; 95 | 96 | const variableValues = { 97 | username: 'noghartt', 98 | password: '123abcAd9=', 99 | }; 100 | 101 | const result = await graphql({ 102 | schema, 103 | source: mutation, 104 | variableValues, 105 | }); 106 | 107 | expect(result.data?.userLoginMutation).toBeNull(); 108 | 109 | expect(result.errors).toBeDefined(); 110 | expect(result.errors[0].message).toBe( 111 | 'This password is incorrect. Please, try again!', 112 | ); 113 | }); 114 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/fixtures/createUser.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from '@fakeddit/types'; 2 | 3 | import { getCounter } from '../../../../test/counters'; 4 | 5 | import { UserModel, User, UserDocument } from '../UserModel'; 6 | 7 | export const createUser = async ( 8 | args?: DeepPartial, 9 | ): Promise => { 10 | const i = getCounter('user'); 11 | 12 | return new UserModel({ 13 | username: `user#${i}`, 14 | email: `user#${i}@example.com`, 15 | password: `password#${i}`, 16 | ...args, 17 | }).save(); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './userRegisterMutation'; 2 | export * from './userLoginMutation'; 3 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/mutations/userLoginMutation.ts: -------------------------------------------------------------------------------- 1 | import { mutationWithClientMutationId } from 'graphql-relay'; 2 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 3 | import bcrypt from 'bcryptjs'; 4 | 5 | import { UserModel } from '../UserModel'; 6 | import { UserType } from '../UserType'; 7 | 8 | import { generateJwtToken } from '../../../auth'; 9 | 10 | export const userLoginMutation = mutationWithClientMutationId({ 11 | name: 'UserLogin', 12 | inputFields: { 13 | username: { type: new GraphQLNonNull(GraphQLString) }, 14 | password: { type: new GraphQLNonNull(GraphQLString) }, 15 | }, 16 | mutateAndGetPayload: async ({ username, password }) => { 17 | const user = await UserModel.findOne({ username }); 18 | 19 | if (!user) { 20 | throw new Error('This user was not registered. Please, try again!'); 21 | } 22 | 23 | // TODO: This conditional should be turn a model method? 24 | if (!bcrypt.compareSync(password, user.password)) { 25 | throw new Error('This password is incorrect. Please, try again!'); 26 | } 27 | 28 | const token = generateJwtToken(user._id); 29 | 30 | return { 31 | token, 32 | user, 33 | }; 34 | }, 35 | outputFields: { 36 | token: { 37 | type: GraphQLString, 38 | resolve: ({ token }) => token, 39 | }, 40 | me: { 41 | type: UserType, 42 | resolve: ({ user }) => user, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/mutations/userRegisterMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | 4 | import { UserModel } from '../UserModel'; 5 | import { UserType } from '../UserType'; 6 | 7 | import { generateJwtToken } from '../../../auth'; 8 | 9 | export const userRegisterMutation = mutationWithClientMutationId({ 10 | name: 'UserRegister', 11 | inputFields: { 12 | username: { type: new GraphQLNonNull(GraphQLString) }, 13 | displayName: { type: GraphQLString }, 14 | email: { type: new GraphQLNonNull(GraphQLString) }, 15 | password: { type: new GraphQLNonNull(GraphQLString) }, 16 | }, 17 | mutateAndGetPayload: async ({ email, ...rest }) => { 18 | const hasUser = 19 | (await UserModel.countDocuments({ email: email.trim() })) > 0; 20 | 21 | if (hasUser) { 22 | throw new Error('This email has been registered. Please try again!'); 23 | } 24 | 25 | const user = new UserModel({ 26 | ...rest, 27 | email, 28 | }); 29 | 30 | await user.save(); 31 | 32 | const token = generateJwtToken(user._id); 33 | 34 | return { 35 | id: user._id, 36 | sucess: 'Congratulations! The user has registered with success!', 37 | token, 38 | }; 39 | }, 40 | outputFields: { 41 | token: { 42 | type: GraphQLString, 43 | resolve: ({ token }) => token, 44 | }, 45 | me: { 46 | type: UserType, 47 | resolve: async ({ id }) => await UserModel.findById(id), 48 | }, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './userMeQuery'; 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/queries/userMeQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldConfig } from 'graphql'; 2 | 3 | import { UserType } from '../UserType'; 4 | import UserLoader from '../UserLoader'; 5 | 6 | export const me: GraphQLFieldConfig = { 7 | type: UserType, 8 | resolve: (_root, _args, context) => 9 | UserLoader.load(context, context.user?._id), 10 | }; 11 | -------------------------------------------------------------------------------- /packages/server/src/schema/MutationType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import * as userMutations from '../modules/user/mutations'; 4 | import * as communityMutations from '../modules/community/mutations'; 5 | 6 | export const MutationType = new GraphQLObjectType({ 7 | name: 'Mutation', 8 | description: 'Root of mutations', 9 | fields: () => ({ 10 | ...userMutations, 11 | ...communityMutations, 12 | }), 13 | }); 14 | -------------------------------------------------------------------------------- /packages/server/src/schema/QueryType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { nodeField, nodesField } from '../modules/graphql/typeRegister'; 4 | import { version } from '../modules/common/queries'; 5 | import { me } from '../modules/user/queries'; 6 | 7 | export const QueryType = new GraphQLObjectType({ 8 | name: 'Query', 9 | description: 'Root of queries', 10 | fields: () => ({ 11 | node: nodeField, 12 | nodes: nodesField, 13 | me, 14 | version, 15 | }), 16 | }); 17 | -------------------------------------------------------------------------------- /packages/server/src/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | 3 | import { QueryType } from './QueryType'; 4 | import { MutationType } from './MutationType'; 5 | 6 | export const schema = new GraphQLSchema({ 7 | query: QueryType, 8 | mutation: MutationType, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/server/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { createTransformer } = require('babel-jest').default; 3 | 4 | const config = require('@fakeddit/babel'); 5 | 6 | module.exports = createTransformer({ ...config }); 7 | -------------------------------------------------------------------------------- /packages/server/test/clearDatabase.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { restartCounters } from './counters'; 4 | 5 | const clearDatabase = async () => { 6 | await mongoose.connection.dropDatabase(); 7 | }; 8 | 9 | export const clearDatabaseAndRestartCounters = async (): Promise => { 10 | await clearDatabase(); 11 | restartCounters(); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/test/connectWithMongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { ConnectOptions } from 'mongoose'; 2 | 3 | declare global { 4 | // eslint-disable-next-line @typescript-eslint/no-namespace 5 | namespace NodeJS { 6 | interface Global { 7 | __MONGO_URI__: string; 8 | __MONGO_DB_NAME__: string; 9 | } 10 | } 11 | } 12 | 13 | const mongooseOptions: ConnectOptions = { 14 | autoIndex: false, 15 | connectTimeoutMS: 10000, 16 | dbName: global.__MONGO_DB_NAME, 17 | }; 18 | 19 | export const connectWithMongoose = async (): Promise => { 20 | jest.setTimeout(20000); 21 | return mongoose.connect(global.__MONGO_URI__, mongooseOptions); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/server/test/counters.ts: -------------------------------------------------------------------------------- 1 | type Counters = Record; 2 | 3 | export const getCounter = (key: string): number => { 4 | if (key in global.__COUNTERS__) { 5 | global.__COUNTERS__[key]++; 6 | 7 | return global.__COUNTERS__[key]; 8 | } 9 | 10 | global.__COUNTERS__[key] = 0; 11 | 12 | return global.__COUNTERS__[key]; 13 | }; 14 | 15 | export const restartCounters = (): void => { 16 | global.__COUNTERS__ = Object.keys(global.__COUNTERS__).reduce( 17 | (prev, curr) => ({ ...prev, [curr]: 0 }), 18 | {}, 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/server/test/disconnectWithMongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | // TODO: Instead of pass this `forEach`, could I simple reassign the property 4 | // with an empty object? 5 | const deleteProperties = (obj: T): void => { 6 | const objNames = Object.keys(obj); 7 | objNames.forEach(itemName => { 8 | delete obj[itemName]; 9 | }); 10 | }; 11 | 12 | export const disconnectWithMongoose = async (): Promise => { 13 | await mongoose.disconnect(); 14 | 15 | mongoose.connections.forEach(connection => { 16 | deleteProperties(connection.models); 17 | deleteProperties(connection.collections); 18 | deleteProperties(connection.models); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/server/test/environment/mongodb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { MongoMemoryServer } = require('mongodb-memory-server-global'); 3 | const NodeEnvironment = require('jest-environment-node'); 4 | 5 | class MongoDbEnvironment extends NodeEnvironment { 6 | constructor(config) { 7 | super(config); 8 | 9 | this.mongod = new MongoMemoryServer({ 10 | binary: { 11 | version: '4.0.5', 12 | }, 13 | }); 14 | } 15 | 16 | async setup() { 17 | await super.setup(); 18 | await this.mongod.start(); 19 | 20 | this.global.__MONGO_URI__ = this.mongod.getUri(); 21 | 22 | this.global.__COUNTERS__ = { 23 | user: 0, 24 | company: 0, 25 | companyFeedbackTopic: 0, 26 | industry: 0, 27 | jobPosting: 0, 28 | group: 0, 29 | groupFeedback: 0, 30 | userFeedback: 0, 31 | userFeedbackRequest: 0, 32 | complaintSubject: 0, 33 | file: 0, 34 | complaintAction: 0, 35 | roleGroup: 0, 36 | candidate: 0, 37 | candidateDependent: 0, 38 | jobRole: 0, 39 | costRevenueCenter: 0, 40 | businessDivision: 0, 41 | salaryRange: 0, 42 | jobExam: 0, 43 | jobExamQuestion: 0, 44 | goalGroup: 0, 45 | goal: 0, 46 | questionOption: 0, 47 | hiringReferral: 0, 48 | groupInterview: 0, 49 | groupInterviewRoom: 0, 50 | positionApplicationStatus: 0, 51 | complaintExternalAuthorData: 0, 52 | customEmoji: 0, 53 | performanceReview: 0, 54 | poll: 0, 55 | pollQuestion: 0, 56 | pollOption: 0, 57 | comment: 0, 58 | address: 0, 59 | application: 0, 60 | headCount: 0, 61 | rowNumber: 0, 62 | reviewTopic: 0, 63 | }; 64 | } 65 | 66 | async teardown() { 67 | await super.teardown(); 68 | await this.mongod.stop(); 69 | this.mongod = null; 70 | this.global = {}; 71 | } 72 | 73 | runScript(script) { 74 | return super.runScript(script); 75 | } 76 | } 77 | 78 | module.exports = MongoDbEnvironment; 79 | -------------------------------------------------------------------------------- /packages/server/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clearDatabase'; 2 | export * from './connectWithMongoose'; 3 | export * from './disconnectWithMongoose'; 4 | export * from './counters'; 5 | -------------------------------------------------------------------------------- /packages/server/test/jest.setup.js: -------------------------------------------------------------------------------- 1 | // require('dotenv').config({ path: '../.env' }); 2 | 3 | // TODO: It should be improved reading de .env.test file 4 | Object.assign(process.env, { 5 | JWT_SECRET: 'testing', 6 | }); 7 | -------------------------------------------------------------------------------- /packages/server/test/upsertModel.ts: -------------------------------------------------------------------------------- 1 | import { Model, Document } from 'mongoose'; 2 | 3 | // TODO: I think that the types here can be improved 4 | export const upsertModel = async ( 5 | model: Model, 6 | createFn: K 7 | ): Promise => { 8 | const data = await model.findOne(); 9 | 10 | if (data) return data; 11 | 12 | return createFn(); 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "allowSyntheticDefaultImports": true, 9 | "baseUrl": ".", 10 | "rootDir": ".", 11 | "outDir": "./dist/tsc", 12 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 13 | }, 14 | "include": ["src/**/*", "./package.json"], 15 | "exclude": ["node_modules", "dist", "src/**/*.test.ts", "test/**/*", "src/**/fixtures/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const WebpackNodeExternals = require('webpack-node-externals'); 4 | 5 | const ReloadServerPlugin = require('./webpack/ReloadServerPlugin'); 6 | 7 | const cwd = process.cwd(); 8 | 9 | module.exports = { 10 | target: 'node', 11 | mode: 'development', 12 | devtool: 'eval-cheap-source-map', 13 | entry: { 14 | server: ['./src/index.ts'], 15 | }, 16 | output: { 17 | path: path.resolve('build'), 18 | filename: 'server.js', 19 | }, 20 | node: { 21 | __dirname: true, 22 | }, 23 | externals: [ 24 | WebpackNodeExternals({ 25 | allowlist: ['webpack/hot/poll?1000'], 26 | }), 27 | WebpackNodeExternals({ 28 | modulesDir: path.resolve(__dirname, '../../node_modules'), 29 | allowlist: [/@fakeddit/], 30 | }), 31 | ], 32 | resolve: { 33 | extensions: ['.ts'], 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.(js|ts)?$/, 39 | use: { 40 | loader: 'babel-loader', 41 | }, 42 | exclude: ['/node_modules/'], 43 | include: [path.join(cwd, 'src'), path.join(cwd, '../')], 44 | }, 45 | ], 46 | }, 47 | plugins: [ 48 | new ReloadServerPlugin({ 49 | script: path.resolve('build', 'server.js'), 50 | }), 51 | new webpack.HotModuleReplacementPlugin(), 52 | new webpack.DefinePlugin({ 53 | 'process.env.NODE_ENV': JSON.stringify('development'), 54 | }), 55 | ], 56 | }; 57 | -------------------------------------------------------------------------------- /packages/server/webpack/ReloadServerPlugin.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'); 2 | const path = require('path'); 3 | 4 | const defaultOptions = { 5 | script: 'server.js', 6 | }; 7 | 8 | class ReloadServerPlugin { 9 | constructor({ script } = defaultOptions) { 10 | this.done = null; 11 | this.workers = []; 12 | 13 | cluster.setupMaster({ 14 | exec: path.resolve(process.cwd(), script), 15 | }); 16 | 17 | cluster.on('online', worker => { 18 | this.workers.push(worker); 19 | 20 | if (this.done) { 21 | this.done(); 22 | } 23 | }); 24 | } 25 | 26 | apply(compiler) { 27 | compiler.hooks.afterEmit.tap( 28 | { 29 | name: 'reload-server', 30 | }, 31 | (_, callback) => { 32 | this.done = callback; 33 | this.workers.forEach(worker => { 34 | try { 35 | process.kill(worker.process.pid, 'SIGTERM'); 36 | } catch (e) { 37 | console.warn(`Unable to kill process #${worker.process.pid}`); 38 | } 39 | }); 40 | 41 | this.workers = []; 42 | 43 | cluster.fork(); 44 | }, 45 | ); 46 | } 47 | } 48 | 49 | module.exports = ReloadServerPlugin; 50 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fakeddit/types", 3 | "version": "0.0.1", 4 | "main": "./src/index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /packages/types/src/DeepPartial.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = { 2 | [P in keyof T]?: T[P] extends (infer U)[] 3 | ? DeepPartial[] 4 | : T[P] extends readonly (infer U)[] 5 | ? readonly DeepPartial[] 6 | : DeepPartial; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/types/src/Maybe.ts: -------------------------------------------------------------------------------- 1 | // TODO: Maybe in the future can be improved 2 | export type Maybe = T | null | undefined; 3 | -------------------------------------------------------------------------------- /packages/types/src/index.ts: -------------------------------------------------------------------------------- 1 | export { DeepPartial } from './DeepPartial'; 2 | export { Maybe } from './Maybe'; 3 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fakeddit/ui", 3 | "version": "0.0.1", 4 | "main": "src/index.ts", 5 | "dependencies": { 6 | "@chakra-ui/react": "^1.7.2", 7 | "@emotion/react": "^11", 8 | "@emotion/styled": "^11", 9 | "framer-motion": "^6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/ui/src/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Button as ChakraButton, ButtonProps } from '@chakra-ui/react'; 2 | 3 | export const buttonTheme = { 4 | baseStyle: { 5 | outline: 'none', 6 | width: '100%', 7 | }, 8 | variants: { 9 | solid: { 10 | bgColor: 'blue.400', 11 | color: 'white', 12 | _hover: { 13 | bgColor: 'blue.500', 14 | }, 15 | }, 16 | }, 17 | }; 18 | 19 | export const Button = (props: ButtonProps) => ; 20 | -------------------------------------------------------------------------------- /packages/ui/src/ErrorText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from '@chakra-ui/react'; 2 | 3 | export const ErrorText = ({ children, ...rest }: TextProps) => ( 4 | 11 | {children} 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /packages/ui/src/VStack.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | import { VStack as ChakraVStack } from '@chakra-ui/react'; 3 | 4 | // TODO: I don't know if there's a better way to do it, but I want to use VStack 5 | // with `alignItems="flex-start"` by default, but it's centered. So, I do it. 6 | export const VStack = (props: ComponentProps) => ( 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /packages/ui/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './theme'; 2 | export { VStack } from './VStack'; 3 | export { Button } from './Button'; 4 | export { ErrorText } from './ErrorText'; 5 | -------------------------------------------------------------------------------- /packages/ui/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react'; 2 | // eslint-disable-next-line import/named 3 | import { Theme } from '@chakra-ui/theme'; 4 | 5 | import { DeepPartial } from '@fakeddit/types'; 6 | 7 | import { buttonTheme as Button } from '../Button'; 8 | 9 | const overrides: DeepPartial = { 10 | components: { 11 | Button, 12 | }, 13 | // TODO: In the future, implements dark theme 14 | config: { 15 | useSystemColorMode: false, 16 | initialColorMode: 'light', 17 | }, 18 | }; 19 | 20 | export const theme = extendTheme(overrides); 21 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "resolveJsonModule": true, 6 | "moduleResolution": "node", 7 | "module": "ESNext", 8 | "lib": ["DOM", "ESNext"], 9 | }, 10 | "include": ["./src"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3000 3 | 4 | API_URL= 5 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | **/__generated__/ 2 | -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # Fakeddit - Web 2 | 3 | The Fakeddit's web application 4 | -------------------------------------------------------------------------------- /packages/web/babel.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const config = require('@fakeddit/babel'); 3 | 4 | const customConfig = { 5 | ...config, 6 | presets: [ 7 | ...config.presets, 8 | ['@babel/preset-react', { runtime: 'automatic' }], 9 | ], 10 | plugins: [['relay', { schema: '../server/graphql/schema.graphql' }]], 11 | }; 12 | 13 | module.exports = customConfig; 14 | -------------------------------------------------------------------------------- /packages/web/jest.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json'); 2 | 3 | module.exports = { 4 | name: pkg.name, 5 | displayName: pkg.name, 6 | testEnvironment: 'jsdom', 7 | testRegex: '(/__tests__/.*|(\\.|/)(test))\\.(j|t)sx?$', 8 | testPathIgnorePatterns: ['/node_modules/', './dist', '__generated__'], 9 | setupFilesAfterEnv: ['/test/jest.setup.js'], 10 | moduleFileExtensions: ['js', 'ts', 'tsx', 'json'], 11 | moduleDirectories: ['node_modules', 'src'], 12 | moduleNameMapper: { 13 | '^@/tests$': '/test/test-utils.tsx', 14 | '^@/(.*)': '/src/$1', 15 | }, 16 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], 17 | transform: { 18 | '^.+\\.(js|ts|tsx)?$': '/test/babel-transformer', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fakeddit/web", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start:dev": "webpack serve --hot", 6 | "relay": "relay-compiler", 7 | "copy-env": "cp .env.example .env" 8 | }, 9 | "dependencies": { 10 | "@chakra-ui/react": "^1.7.2", 11 | "@emotion/react": "^11", 12 | "@emotion/styled": "^11", 13 | "formik": "^2.2.9", 14 | "framer-motion": "^6", 15 | "react": "17.0.2", 16 | "react-dom": "17.0.2", 17 | "react-relay": "^12.0.0", 18 | "react-router-dom": "^6.0.2", 19 | "relay-runtime": "^13.0.1", 20 | "yup": "^0.32.11" 21 | }, 22 | "devDependencies": { 23 | "@babel/preset-react": "^7.16.0", 24 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 25 | "@testing-library/jest-dom": "^5.16.1", 26 | "@testing-library/react": "^12.1.2", 27 | "@testing-library/user-event": "^13.5.0", 28 | "@types/react": "^17.0.36", 29 | "@types/react-dom": "^17.0.11", 30 | "@types/react-relay": "^13.0.0", 31 | "@types/relay-runtime": "^12.0.0", 32 | "@types/relay-test-utils": "^6.0.5", 33 | "babel-loader": "^8.2.3", 34 | "babel-plugin-relay": "^13.0.1", 35 | "dotenv": "^14.3.2", 36 | "dotenv-webpack": "^7.0.3", 37 | "graphql": "^15.8.0", 38 | "history": "^5.1.0", 39 | "html-loader": "^3.1.0", 40 | "html-webpack-plugin": "^5.5.0", 41 | "react-refresh": "^0.11.0", 42 | "relay-compiler": "^12.0.0", 43 | "relay-compiler-language-typescript": "^15.0.0", 44 | "relay-config": "^12.0.0", 45 | "relay-test-utils": "^13.0.1", 46 | "webpack": "^5.67.0", 47 | "webpack-cli": "^4.9.2", 48 | "webpack-dev-server": "^4.5.0", 49 | "webpack-merge": "^5.8.0", 50 | "webpack-plugin-serve": "^1.5.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Fakeddit 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/web/relay.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | language: 'typescript', 3 | src: './src', 4 | schema: '../server/graphql/schema.graphql', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/web/src/@types/node.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: 'development' | 'production'; 5 | PORT: number; 6 | API_URL: string; 7 | } 8 | } 9 | } 10 | 11 | export {}; 12 | -------------------------------------------------------------------------------- /packages/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Providers } from './Providers'; 4 | import { Routes } from './Routes'; 5 | 6 | export const App = () => ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /packages/web/src/Providers.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | import { RelayEnvironmentProvider } from 'react-relay'; 3 | import { ChakraProvider } from '@chakra-ui/react'; 4 | 5 | import { theme } from '@fakeddit/ui'; 6 | 7 | import { RelayEnvironment } from './relay/RelayEnvironment'; 8 | import { AuthProvider } from './modules/auth/AuthContext'; 9 | 10 | interface Props { 11 | children: React.ReactElement; 12 | } 13 | 14 | export const Providers = ({ children }: Props) => ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /packages/web/src/Routes.tsx: -------------------------------------------------------------------------------- 1 | // TODO: I don't know if renaming `Routes` as `Router` is a good idea, because 2 | // someone that is reading this code can be confused with BrowserRouter. So, I 3 | // think that, in the future, this will be changed. 4 | import { Routes as Router, Route } from 'react-router-dom'; 5 | 6 | import { LoginRoutes } from './modules/users/LoginRoutes'; 7 | import { RequireAuthLayout } from './modules/auth/RequireAuthLayout'; 8 | import { FeedPage } from './modules/feed/FeedPage'; 9 | 10 | export const Routes = () => ( 11 | 12 | } /> 13 | }> 14 | } /> 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /packages/web/src/config.ts: -------------------------------------------------------------------------------- 1 | const { API_URL, NODE_ENV } = process.env; 2 | 3 | export const config = { 4 | NODE_ENV, 5 | API_URL, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { App } from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ); 12 | -------------------------------------------------------------------------------- /packages/web/src/modules/auth/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useCallback } from 'react'; 2 | 3 | import { Maybe } from '@fakeddit/types'; 4 | 5 | import { getAuthToken, updateAuthToken } from './security'; 6 | 7 | export interface AuthContextValue { 8 | token: Maybe | undefined; 9 | signin: (token: Maybe | undefined, cb: VoidFunction) => void; 10 | signout: (cb: VoidFunction) => void; 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 14 | export const AuthContext = React.createContext(null!); 15 | 16 | export const AuthProvider = ({ children }: { children: React.ReactNode }) => { 17 | const [userToken, setUserToken] = useState(() => 18 | getAuthToken(), 19 | ); 20 | 21 | const signin = useCallback((token, cb) => { 22 | updateAuthToken(token); 23 | setUserToken(token); 24 | cb(); 25 | }, []); 26 | 27 | const signout = useCallback(cb => { 28 | setUserToken(null); 29 | updateAuthToken(); 30 | cb(); 31 | }, []); 32 | 33 | const value = useMemo( 34 | () => ({ 35 | token: userToken, 36 | signin, 37 | signout, 38 | }), 39 | [userToken, signin, signout], 40 | ); 41 | 42 | return {children}; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/web/src/modules/auth/RequireAuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, useLocation } from 'react-router-dom'; 2 | 3 | import { useAuth } from './useAuth'; 4 | 5 | export const RequireAuthLayout = () => { 6 | const location = useLocation(); 7 | const { token } = useAuth(); 8 | 9 | if (!token) { 10 | return ; 11 | } 12 | 13 | return ; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/web/src/modules/auth/security.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@fakeddit/types'; 2 | 3 | const JWT_TOKEN_KEY = '@user/TOKEN'; 4 | 5 | export const getAuthToken = () => localStorage.getItem(JWT_TOKEN_KEY); 6 | 7 | export const updateAuthToken = (token?: Maybe) => { 8 | if (!token) { 9 | localStorage.removeItem(JWT_TOKEN_KEY); 10 | } else { 11 | localStorage.setItem(JWT_TOKEN_KEY, token); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/web/src/modules/auth/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AuthContext } from './AuthContext'; 4 | 5 | export const useAuth = () => useContext(AuthContext); 6 | -------------------------------------------------------------------------------- /packages/web/src/modules/feed/FeedPage.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { useAuth } from '../auth/useAuth'; 3 | 4 | export const FeedPage = () => { 5 | const navigate = useNavigate(); 6 | const { signout } = useAuth(); 7 | 8 | const handleLogout = () => { 9 | signout(() => { 10 | navigate('/', { replace: true }); 11 | }); 12 | }; 13 | 14 | return ( 15 |
16 |

You're logged in!

17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/LoginLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, Navigate, useLocation } from 'react-router-dom'; 2 | import { Box, Flex, Heading, Text } from '@chakra-ui/react'; 3 | 4 | import { Link } from '@/shared-components/Link'; 5 | 6 | import { useAuth } from '../auth/useAuth'; 7 | 8 | const SignupOrLoginLink = ({ pathname }: { pathname: string }) => { 9 | const isSignupScreen = pathname === '/signup'; 10 | 11 | const text = isSignupScreen ? 'Already a Fakedditor?' : 'New to Fakeddit?'; 12 | const link = { 13 | to: isSignupScreen ? '/' : '/signup', 14 | text: isSignupScreen ? 'Log In' : 'Sign Up', 15 | }; 16 | 17 | return ( 18 | 19 | {text}{' '} 20 | 21 | {link.text} 22 | 23 | 24 | ); 25 | }; 26 | 27 | export const LoginLayout = () => { 28 | const { pathname } = useLocation(); 29 | const { token } = useAuth(); 30 | 31 | if (token) { 32 | return ; 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | {pathname === '/signup' ? 'Sign Up' : 'Log In'} 42 | 43 | 44 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. Quasi, 45 | ullam, mollitia consectetur voluptatem sed veritatis illum, eveniet 46 | ipsa quas dolores ea adipisci. Ducimus eum perferendis quos ipsum? 47 | Recusandae, illo sint. 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Spinner } from '@chakra-ui/react'; 3 | import { useMutation } from 'react-relay'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { useFormik, FormikProvider, Form } from 'formik'; 6 | import * as Yup from 'yup'; 7 | 8 | import { VStack, Button, ErrorText } from '@fakeddit/ui'; 9 | 10 | import { InputField } from '@/shared-components/InputField'; 11 | 12 | import { useAuth } from '../auth/useAuth'; 13 | 14 | import { UserLogin } from './UserLoginMutation'; 15 | import type { UserLoginMutation } from './__generated__/UserLoginMutation.graphql'; 16 | 17 | export const LoginPage = () => { 18 | const { signin } = useAuth(); 19 | const navigate = useNavigate(); 20 | 21 | const [error, setError] = useState({ 22 | status: false, 23 | message: '', 24 | }); 25 | 26 | const [handleUserLogin] = useMutation(UserLogin); 27 | 28 | const formikValue = useFormik({ 29 | initialValues: { username: '', password: '' }, 30 | validateOnMount: true, 31 | validationSchema: Yup.object().shape({ 32 | username: Yup.string() 33 | .min(3, 'The username needs at least 3 characters') 34 | .max(25) 35 | .required('Username is required'), 36 | // TODO: The rules to write a safe password need to be improved adding some 37 | password: Yup.string() 38 | .min(8, 'The password needs at least 8 characters') 39 | .required('Password is required'), 40 | }), 41 | onSubmit: (values, actions) => { 42 | handleUserLogin({ 43 | variables: values, 44 | onCompleted: ({ userLoginMutation }, error) => { 45 | if (error && error.length > 0) { 46 | const inputs: Array = ['password', 'username']; 47 | 48 | inputs.forEach(input => { 49 | actions.setFieldValue(input, '', false); 50 | actions.setFieldTouched(input, false); 51 | }); 52 | 53 | actions.setSubmitting(false); 54 | 55 | setError({ status: true, message: error[0].message }); 56 | return; 57 | } 58 | 59 | signin(userLoginMutation?.token, () => { 60 | navigate('/feed', { replace: true }); 61 | }); 62 | }, 63 | }); 64 | }, 65 | }); 66 | 67 | const { isValid, isSubmitting } = formikValue; 68 | 69 | return ( 70 | 71 |
72 | 73 | 74 | 80 | 83 | {error.status && {error.message}} 84 | 85 |
86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/LoginRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom'; 2 | 3 | import { LoginLayout } from './LoginLayout'; 4 | 5 | import { LoginPage } from './LoginPage'; 6 | import { SignupPage } from './SignupPage'; 7 | 8 | export const LoginRoutes = () => ( 9 | 10 | }> 11 | } /> 12 | } /> 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/SignupPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Spinner } from '@chakra-ui/react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { useMutation } from 'react-relay'; 5 | import { FormikProvider, Form, useFormik } from 'formik'; 6 | import * as Yup from 'yup'; 7 | 8 | import { VStack, Button, ErrorText } from '@fakeddit/ui'; 9 | 10 | import { InputField } from '@/shared-components/InputField'; 11 | 12 | import { useAuth } from '../auth/useAuth'; 13 | 14 | import { UserRegister } from './UserRegisterMutation'; 15 | import type { UserRegisterMutation } from './__generated__/UserRegisterMutation.graphql'; 16 | 17 | export const SignupPage = () => { 18 | const { signin } = useAuth(); 19 | const navigate = useNavigate(); 20 | 21 | const [error, setError] = useState({ 22 | status: false, 23 | message: '', 24 | }); 25 | 26 | const [userRegister, isPending] = 27 | useMutation(UserRegister); 28 | 29 | const formikValue = useFormik({ 30 | initialValues: { 31 | username: '', 32 | email: '', 33 | password: '', 34 | }, 35 | validateOnMount: true, 36 | validationSchema: Yup.object().shape({ 37 | email: Yup.string().email('Invalid email').required('Email is required'), 38 | username: Yup.string() 39 | .min(3, 'The username needs at least 3 characters') 40 | .max(25) 41 | .required('Username is required'), 42 | // TODO: The rules to write a safe password need to be improved adding some 43 | password: Yup.string() 44 | .min(8, 'The password needs at least 8 characters') 45 | .required('Password is required'), 46 | }), 47 | onSubmit: (values, actions) => { 48 | userRegister({ 49 | variables: values, 50 | onCompleted: ({ userRegisterMutation }, error) => { 51 | if (error && error.length > 0) { 52 | const inputs: Array = [ 53 | 'email', 54 | 'password', 55 | 'username', 56 | ]; 57 | 58 | inputs.forEach(input => { 59 | actions.setFieldValue(input, '', false); 60 | actions.setFieldTouched(input, false); 61 | }); 62 | 63 | actions.setSubmitting(false); 64 | 65 | setError({ status: true, message: error[0].message }); 66 | return; 67 | } 68 | 69 | signin(userRegisterMutation?.token, () => { 70 | navigate('/feed', { replace: true }); 71 | }); 72 | }, 73 | }); 74 | }, 75 | }); 76 | 77 | const { isSubmitting, isValid } = formikValue; 78 | 79 | return ( 80 | 81 |
82 | 83 | 89 | 90 | 96 | 99 | 100 | {error.status && {error.message}} 101 |
102 |
103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/UserLoginMutation.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | 3 | export const UserLogin = graphql` 4 | mutation UserLoginMutation($username: String!, $password: String!) { 5 | userLoginMutation(input: { username: $username, password: $password }) { 6 | token 7 | me { 8 | username 9 | displayName 10 | email 11 | } 12 | } 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/UserRegisterMutation.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | 3 | export const UserRegister = graphql` 4 | mutation UserRegisterMutation( 5 | $username: String! 6 | $displayName: String 7 | $email: String! 8 | $password: String! 9 | ) { 10 | userRegisterMutation( 11 | input: { 12 | username: $username 13 | displayName: $displayName 14 | email: $email 15 | password: $password 16 | } 17 | ) { 18 | token 19 | me { 20 | id 21 | username 22 | email 23 | } 24 | } 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/__tests__/LoginPage.test.tsx: -------------------------------------------------------------------------------- 1 | import userEvent from '@testing-library/user-event'; 2 | import { createMemoryHistory } from 'history'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import { render, cleanup, screen, waitFor } from '@testing-library/react'; 5 | import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils'; 6 | 7 | import { WithProviders } from '@/../test/WithProviders'; 8 | import { TestRouter } from '@/../test/TestRouter'; 9 | 10 | import { Routes } from '@/Routes'; 11 | 12 | afterEach(cleanup); 13 | 14 | it('should navigate to /feed after login correctly the user', async () => { 15 | const environment = createMockEnvironment(); 16 | const history = createMemoryHistory(); 17 | 18 | render( 19 | 20 | 21 | 22 | 23 | , 24 | ); 25 | 26 | const variables = { 27 | username: 'johndoe', 28 | password: 'thisisanawkwardbutcorrectlypassword', 29 | }; 30 | 31 | await waitFor(() => expect(screen.getByRole('button')).toBeDisabled()); 32 | 33 | userEvent.type(screen.getByPlaceholderText('Username'), variables.username); 34 | userEvent.type(screen.getByPlaceholderText('Password'), variables.password); 35 | 36 | await waitFor(() => expect(screen.getByRole('button')).toBeEnabled()); 37 | 38 | userEvent.click(screen.getByRole('button')); 39 | 40 | expect(history.location.pathname).toBe('/'); 41 | 42 | await waitFor(() => { 43 | const operation = environment.mock.getMostRecentOperation(); 44 | 45 | expect(operation.request.variables).toMatchObject(variables); 46 | 47 | environment.mock.resolve( 48 | operation, 49 | MockPayloadGenerator.generate(operation), 50 | ); 51 | }); 52 | 53 | expect(history.location.pathname).toBe('/feed'); 54 | }); 55 | 56 | it('should display an error and disable the button when type in username input', async () => { 57 | render( 58 | 59 | 60 | 61 | 62 | , 63 | ); 64 | 65 | await waitFor(() => screen.getByRole('button')); 66 | expect(screen.getByRole('button')).toBeDisabled(); 67 | 68 | const usernameInput = screen.getByPlaceholderText('Username'); 69 | 70 | userEvent.click(usernameInput); 71 | await waitFor(() => usernameInput.blur()); 72 | 73 | expect(screen.getByRole('button')).toBeDisabled(); 74 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 75 | 'Username is required', 76 | ); 77 | 78 | userEvent.type(usernameInput, 'ab'); 79 | await waitFor(() => usernameInput.blur()); 80 | 81 | expect(screen.getByRole('button')).toBeDisabled(); 82 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 83 | 'The username needs at least 3 characters', 84 | ); 85 | 86 | userEvent.type(usernameInput, 'someawkwardvaluethatisbiggerthanusual'); 87 | await waitFor(() => usernameInput.blur()); 88 | 89 | expect(screen.getByRole('button')).toBeDisabled(); 90 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 91 | 'username must be at most 25 characters', 92 | ); 93 | }); 94 | 95 | it('should display an error and disable the button when type in password input', async () => { 96 | render( 97 | 98 | 99 | 100 | 101 | , 102 | ); 103 | 104 | await waitFor(() => screen.getByRole('button')); 105 | expect(screen.getByRole('button')).toBeDisabled(); 106 | 107 | const passwordInput = screen.getByPlaceholderText('Password'); 108 | 109 | userEvent.click(passwordInput); 110 | await waitFor(() => passwordInput.blur()); 111 | 112 | expect(screen.getByRole('button')).toBeDisabled(); 113 | expect(screen.getByTestId('error-message-password')).toHaveTextContent( 114 | 'Password is required', 115 | ); 116 | 117 | userEvent.type(passwordInput, 'ab'); 118 | await waitFor(() => passwordInput.blur()); 119 | 120 | expect(screen.getByRole('button')).toBeDisabled(); 121 | expect(screen.getByTestId('error-message-password')).toHaveTextContent( 122 | 'The password needs at least 8 characters', 123 | ); 124 | }); 125 | 126 | it('should display an general error on the form if return error from the mutation', async () => { 127 | const environment = createMockEnvironment(); 128 | 129 | render( 130 | 131 | 132 | 133 | 134 | , 135 | ); 136 | 137 | expect(await screen.findByRole('button')).toBeDisabled(); 138 | 139 | await waitFor(() => screen.getByRole('button')); 140 | expect(screen.getByRole('button')).toBeDisabled(); 141 | 142 | userEvent.type(screen.getByPlaceholderText('Username'), 'johndoe'); 143 | userEvent.type( 144 | screen.getByPlaceholderText('Password'), 145 | 'thisisanawkwardpassword', 146 | ); 147 | 148 | expect(await screen.findByRole('button')).toBeEnabled(); 149 | 150 | userEvent.click(screen.getByRole('button')); 151 | 152 | await waitFor(() => { 153 | const operation = environment.mock.getMostRecentOperation(); 154 | 155 | environment.mock.resolve(operation, { 156 | data: { 157 | userLoginMutation: null, 158 | }, 159 | errors: [ 160 | { 161 | message: 'Some error message from backend', 162 | }, 163 | ], 164 | }); 165 | }); 166 | 167 | // TODO: I think that it could be improved adding getBy based on ARIA maybe. 168 | expect(screen.getByTestId('form-error-text')).toHaveTextContent( 169 | 'Some error message from backend', 170 | ); 171 | }); 172 | -------------------------------------------------------------------------------- /packages/web/src/modules/users/__tests__/SignupPage.test.tsx: -------------------------------------------------------------------------------- 1 | import userEvent from '@testing-library/user-event'; 2 | import { createMemoryHistory } from 'history'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import { render, cleanup, screen, waitFor } from '@testing-library/react'; 5 | import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils'; 6 | 7 | import { WithProviders } from '@/../test/WithProviders'; 8 | import { TestRouter } from '@/../test/TestRouter'; 9 | 10 | import { Routes } from '@/Routes'; 11 | 12 | afterEach(cleanup); 13 | 14 | it('should navigate to /feed after signup correctly the user', async () => { 15 | const environment = createMockEnvironment(); 16 | const history = createMemoryHistory({ 17 | initialEntries: ['/signup'], 18 | }); 19 | 20 | render( 21 | 22 | 23 | 24 | 25 | , 26 | ); 27 | 28 | const variables = { 29 | email: 'john@doe.com', 30 | username: 'johndoe', 31 | password: 'thisisanawkwardpassword', 32 | }; 33 | 34 | await waitFor(() => expect(screen.getByRole('button')).toBeDisabled()); 35 | 36 | userEvent.type(screen.getByPlaceholderText('Email'), variables.email); 37 | userEvent.type(screen.getByPlaceholderText('Username'), variables.username); 38 | userEvent.type(screen.getByPlaceholderText('Password'), variables.password); 39 | 40 | await waitFor(() => expect(screen.getByRole('button')).toBeEnabled()); 41 | 42 | userEvent.click(screen.getByRole('button')); 43 | 44 | expect(history.location.pathname).toBe('/signup'); 45 | 46 | await waitFor(() => { 47 | const operation = environment.mock.getMostRecentOperation(); 48 | 49 | expect(operation.request.variables).toMatchObject(variables); 50 | 51 | environment.mock.resolve( 52 | operation, 53 | MockPayloadGenerator.generate(operation), 54 | ); 55 | }); 56 | 57 | expect(history.location.pathname).toBe('/feed'); 58 | }); 59 | 60 | it("should maintain disable the button if the email isn't correct", async () => { 61 | render( 62 | 63 | 64 | 65 | 66 | , 67 | ); 68 | 69 | await waitFor(() => screen.getByRole('button')); 70 | expect(screen.getByRole('button')).toBeDisabled(); 71 | 72 | const emailInput = screen.getByPlaceholderText('Email'); 73 | 74 | userEvent.click(emailInput); 75 | await waitFor(() => emailInput.blur()); 76 | 77 | expect(screen.getByTestId('error-message-email')).toHaveTextContent( 78 | 'Email is required', 79 | ); 80 | 81 | userEvent.type(emailInput, 'someawkwardvaluethatisntanemail'); 82 | await waitFor(() => emailInput.blur()); 83 | 84 | expect(screen.getByRole('button')).toBeDisabled(); 85 | expect(screen.getByTestId('error-message-email')).toHaveTextContent( 86 | 'Invalid email', 87 | ); 88 | }); 89 | 90 | it("should maintain disabled the button if username isn't correct", async () => { 91 | render( 92 | 93 | 94 | 95 | 96 | , 97 | ); 98 | 99 | await waitFor(() => screen.getByRole('button')); 100 | expect(screen.getByRole('button')).toBeDisabled(); 101 | 102 | const usernameInput = screen.getByPlaceholderText('Username'); 103 | 104 | userEvent.click(usernameInput); 105 | await waitFor(() => usernameInput.blur()); 106 | 107 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 108 | 'Username is required', 109 | ); 110 | 111 | userEvent.type(usernameInput, 'ab'); 112 | await waitFor(() => usernameInput.blur()); 113 | 114 | expect(screen.getByRole('button')).toBeDisabled(); 115 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 116 | 'The username needs at least 3 characters', 117 | ); 118 | 119 | userEvent.type(usernameInput, 'someawkwardvaluethatisbiggerthanusual'); 120 | await waitFor(() => usernameInput.blur()); 121 | 122 | expect(screen.getByRole('button')).toBeDisabled(); 123 | expect(screen.getByTestId('error-message-username')).toHaveTextContent( 124 | 'username must be at most 25 characters', 125 | ); 126 | }); 127 | 128 | it("should maintain disabled the button if password isn't correct", async () => { 129 | render( 130 | 131 | 132 | 133 | 134 | , 135 | ); 136 | 137 | await waitFor(() => screen.getByRole('button')); 138 | expect(screen.getByRole('button')).toBeDisabled(); 139 | 140 | const passwordInput = screen.getByPlaceholderText('Password'); 141 | 142 | userEvent.click(passwordInput); 143 | await waitFor(() => passwordInput.blur()); 144 | 145 | expect(screen.getByTestId('error-message-password')).toHaveTextContent( 146 | 'Password is required', 147 | ); 148 | 149 | userEvent.type(passwordInput, 'ab'); 150 | await waitFor(() => passwordInput.blur()); 151 | 152 | expect(screen.getByRole('button')).toBeDisabled(); 153 | expect(screen.getByTestId('error-message-password')).toHaveTextContent( 154 | 'The password needs at least 8 characters', 155 | ); 156 | }); 157 | 158 | it('should display an general error on the form if return error from the mutation', async () => { 159 | const environment = createMockEnvironment(); 160 | 161 | render( 162 | 163 | 164 | 165 | 166 | , 167 | ); 168 | 169 | expect(await screen.findByRole('button')).toBeDisabled(); 170 | 171 | const variables = { 172 | email: 'john@doe.com', 173 | username: 'johndoe', 174 | password: 'thisisanawkwardpassword', 175 | }; 176 | 177 | await waitFor(() => expect(screen.getByRole('button')).toBeDisabled()); 178 | 179 | userEvent.type(screen.getByPlaceholderText('Email'), variables.email); 180 | userEvent.type(screen.getByPlaceholderText('Username'), variables.username); 181 | userEvent.type(screen.getByPlaceholderText('Password'), variables.password); 182 | 183 | expect(await screen.findByRole('button')).toBeEnabled(); 184 | 185 | userEvent.click(screen.getByRole('button')); 186 | 187 | await waitFor(() => { 188 | const operation = environment.mock.getMostRecentOperation(); 189 | 190 | environment.mock.resolve(operation, { 191 | data: { 192 | userRegisterMutation: null, 193 | }, 194 | errors: [ 195 | { 196 | message: 'Some error message from backend', 197 | }, 198 | ], 199 | }); 200 | }); 201 | 202 | // TODO: I think that it could be improved adding getBy based on ARIA maybe. 203 | expect(screen.getByTestId('form-error-text')).toHaveTextContent( 204 | 'Some error message from backend', 205 | ); 206 | }); 207 | -------------------------------------------------------------------------------- /packages/web/src/relay/RelayEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Environment, 3 | Network, 4 | RecordSource, 5 | Store, 6 | FetchFunction, 7 | } from 'relay-runtime'; 8 | 9 | import { fetchGraphQL } from './fetchGraphQL'; 10 | 11 | const fetchRelay: FetchFunction = async (params, variables) => 12 | fetchGraphQL(params.text as string, variables); 13 | 14 | export const RelayEnvironment = new Environment({ 15 | network: Network.create(fetchRelay), 16 | store: new Store(new RecordSource()), 17 | }); 18 | -------------------------------------------------------------------------------- /packages/web/src/relay/fetchGraphQL.ts: -------------------------------------------------------------------------------- 1 | import { Variables } from 'relay-runtime'; 2 | 3 | import { config } from '@/config'; 4 | 5 | export const fetchGraphQL = async (query: string, variables: Variables) => { 6 | const response = await fetch(config.API_URL, { 7 | method: 'POST', 8 | headers: { 9 | 'Access-Control-Allow-Origin': '*', 10 | 'Content-Type': 'application/json', 11 | }, 12 | body: JSON.stringify({ 13 | query, 14 | variables, 15 | }), 16 | }); 17 | 18 | const data = await response.json(); 19 | 20 | return data; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/web/src/shared-components/InputField.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormErrorMessage, 4 | Input as ChakraInput, 5 | InputProps, 6 | } from '@chakra-ui/react'; 7 | import { ErrorMessage, useField } from 'formik'; 8 | 9 | type Props = InputProps & { 10 | name: string; 11 | shouldValidate?: boolean; 12 | }; 13 | 14 | export const InputField = ({ 15 | name, 16 | shouldValidate = false, 17 | ...rest 18 | }: Props) => { 19 | const [field, meta] = useField(name); 20 | 21 | const hasAnErrorAndHasBeenTouched = !!meta.error && meta.touched; 22 | 23 | const propsWhenShouldValidateProps = { 24 | isInvalid: hasAnErrorAndHasBeenTouched, 25 | }; 26 | 27 | return ( 28 | 29 | 30 | {shouldValidate && ( 31 | 32 | {error => ( 33 | 38 | {error} 39 | 40 | )} 41 | 42 | )} 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/web/src/shared-components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as RRLink } from 'react-router-dom'; 2 | import { chakra } from '@chakra-ui/react'; 3 | 4 | export const Link = chakra(RRLink, { 5 | baseStyle: { 6 | color: 'blue.500', 7 | fontWeight: 'bold', 8 | _hover: { 9 | textDecoration: 'underline', 10 | }, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/web/test/TestRouter.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useLayoutEffect } from 'react'; 2 | import { Router } from 'react-router-dom'; 3 | import type { History } from 'history'; 4 | 5 | interface TestRouterProps { 6 | history: History; 7 | children: React.ReactNode; 8 | basename?: string; 9 | } 10 | 11 | export const TestRouter = ({ 12 | history, 13 | basename, 14 | children, 15 | }: TestRouterProps) => { 16 | const [state, setState] = useState({ 17 | action: history.action, 18 | location: history.location, 19 | }); 20 | 21 | useLayoutEffect(() => history.listen(setState), [history]); 22 | 23 | return ( 24 | 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/web/test/WithProviders.tsx: -------------------------------------------------------------------------------- 1 | import { RelayEnvironmentProvider, Environment } from 'react-relay'; 2 | import { ChakraProvider } from '@chakra-ui/react'; 3 | 4 | import { theme } from '@fakeddit/ui'; 5 | 6 | import { RelayEnvironment } from '../src//relay/RelayEnvironment'; 7 | import { AuthProvider } from '../src/modules/auth/AuthContext'; 8 | 9 | interface Props { 10 | children: React.ReactElement; 11 | relayEnvironment?: Environment; 12 | } 13 | 14 | export const WithProviders = ({ 15 | children, 16 | relayEnvironment = RelayEnvironment, 17 | }: Props) => ( 18 | 19 | 20 | {children} 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /packages/web/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const { createTransformer } = require('babel-jest').default; 2 | 3 | const config = require('../babel.config'); 4 | 5 | module.exports = createTransformer(config); 6 | -------------------------------------------------------------------------------- /packages/web/test/jest.setup.js: -------------------------------------------------------------------------------- 1 | require('@testing-library/jest-dom'); 2 | 3 | jest.mock('../src/relay/RelayEnvironment.ts', () => { 4 | const { createMockEnvironment } = require('relay-test-utils'); 5 | return createMockEnvironment(); 6 | }); 7 | 8 | // TODO: For some reason, some tests were causing interference in another tests 9 | // so was necessary to clean the localStorage to run the tests correctly. 10 | afterEach(() => global.window.localStorage.clear()); 11 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "resolveJsonModule": true, 6 | "moduleResolution": "node", 7 | "module": "ESNext", 8 | "lib": ["DOM", "ESNext"], 9 | "baseUrl": "./src", 10 | "paths": { 11 | "@/*": ["./*"], 12 | "@/tests/*": ["./../test"] 13 | } 14 | }, 15 | "include": ["./src"], 16 | "exclude": ["node_modules", "./src/**/*.test.tsx", "./test"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 5 | const dotenv = require('dotenv').config({ 6 | path: path.resolve(__dirname, '.env'), 7 | }); 8 | 9 | const cwd = process.cwd(); 10 | 11 | // TODO: It should be turn `webpack.config.dev.js` in the future 12 | module.exports = { 13 | mode: 'development', 14 | devtool: 'cheap-module-source-map', 15 | context: path.resolve(cwd, './'), 16 | entry: './src/index.tsx', 17 | output: { 18 | path: path.join(cwd, 'dist'), 19 | }, 20 | resolve: { 21 | extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], 22 | alias: { 23 | '@': path.resolve(__dirname, 'src'), 24 | }, 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.(js|jsx|ts|tsx)$/, 30 | exclude: /node_modules/, 31 | use: ['babel-loader?cacheDirectory'], 32 | }, 33 | ], 34 | }, 35 | devServer: { 36 | port: process.env.PORT, 37 | hot: true, 38 | compress: true, 39 | historyApiFallback: true, 40 | }, 41 | plugins: [ 42 | // new Dotenv({ 43 | // path: './.env', 44 | // safe: true, 45 | // ignoreStub: true, 46 | // }), 47 | // TODO: It's working but should I do it or should I use dotenv-webpack package? 48 | // See this issue for more details: https://github.com/mrsteele/dotenv-webpack/issues/271 49 | new webpack.DefinePlugin({ 50 | 'process.env': JSON.stringify(dotenv.parsed), 51 | }), 52 | new ReactRefreshPlugin(), 53 | new HtmlWebpackPlugin({ 54 | template: './public/index.html', 55 | }), 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES5", 5 | "jsx": "react", 6 | "lib": ["esnext"], 7 | "incremental": true, 8 | "composite": true, 9 | "strict": true, 10 | "declarationMap": true, 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "noImplicitReturns": true, 15 | "noImplicitAny": true, 16 | "noUnusedLocals": true, 17 | "forceConsistentCasingInFileNames": true 18 | }, 19 | "exclude": ["node_modules", "dist", "*.d.ts"] 20 | } 21 | --------------------------------------------------------------------------------