[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".eslintignore",
    "content": "node_modules\n\ndist\n\ndata\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"env\": {\n    \"browser\": true,\n    \"es6\": true,\n    \"node\": true\n  },\n  \"parser\": \"@typescript-eslint/parser\",\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:react/recommended\",\n    \"plugin:jest/recommended\",\n    \"prettier\",\n    \"plugin:prettier/recommended\",\n    \"prettier/@typescript-eslint\",\n    \"prettier/react\"\n  ],\n  \"plugins\": [\"react-hooks\"],\n  \"globals\": {\n    \"Atomics\": \"readonly\",\n    \"SharedArrayBuffer\": \"readonly\"\n  },\n  \"parserOptions\": {\n    \"ecmaFeatures\": {\n      \"jsx\": true\n    },\n    \"ecmaVersion\": 2018,\n    \"sourceType\": \"module\"\n  },\n  \"settings\": {\n    \"react\": {\n      \"version\": \"latest\"\n    }\n  },\n  \"rules\": {\n    \"@typescript-eslint/explicit-function-return-type\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"react-hooks/rules-of-hooks\": \"error\",\n    \"react-hooks/exhaustive-deps\": \"warn\"\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\n\n*.log\n\ndist\n\n.env\n\ndata\n"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\n\ndist\n\ndata\n\ntsconfig.json\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"jsxSingleQuote\": true\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"[javascript]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[javascriptreact]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[typescript]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[typescriptreact]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"eslint.autoFixOnSave\": true,\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    { \"language\": \"typescript\", \"autoFix\": true },\n    { \"language\": \"typescriptreact\", \"autoFix\": true }\n  ]\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) Alex\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "version: '3'\n\nservices:\n  db:\n    image: mongo\n    container_name: chat-db\n    ports:\n      - '27017:27017'\n    user: $UID\n    environment:\n      - MONGO_INITDB_ROOT_USERNAME=root\n      - MONGO_INITDB_ROOT_PASSWORD=secret\n      - MONGO_INITDB_DATABASE=chat\n      - DB_USERNAME=admin\n      - DB_PASSWORD=secret\n    volumes:\n      - ./mongo-init.sh:/docker-entrypoint-initdb.d/mongo-init.sh\n      - ./data:/data/db\n\n  cache:\n    image: redis:alpine\n    container_name: chat-cache\n    ports:\n      - '6379:6379'\n    command: ['--requirepass \"secret\"']\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3\"\n\nservices:\n  api:\n    build: packages/api\n    image: chat-api\n    container_name: chat-api\n    restart: unless-stopped\n    expose:\n      - \"3000\"\n    environment:\n      - DB_USERNAME\n      - DB_PASSWORD\n      - DB_HOST=db\n      - DB_NAME=$MONGO_INITDB_DATABASE\n      - SESS_SECRET\n      - REDIS_HOST=cache\n      - REDIS_PASSWORD\n    depends_on:\n      - db\n      - cache\n\n  web:\n    build: packages/web\n    image: chat-web\n    container_name: chat-web\n    expose:\n      - \"80\"\n    depends_on:\n      - api\n\n  db:\n    image: mongo\n    container_name: chat-db\n    user: $UID\n    expose:\n      - \"27017\"\n    environment:\n      - MONGO_INITDB_ROOT_USERNAME\n      - MONGO_INITDB_ROOT_PASSWORD\n      - MONGO_INITDB_DATABASE\n      - DB_USERNAME\n      - DB_PASSWORD\n    volumes:\n      - ./mongo-init.sh:/docker-entrypoint-initdb.d/mongo-init.sh\n      - ./data:/data/db\n\n  cache:\n    image: redis:alpine\n    container_name: chat-cache\n    expose:\n      - \"6379\"\n    environment:\n      - REDIS_PASSWORD\n    command: ['--requirepass \"$REDIS_PASSWORD\"']\n\n  proxy:\n    image: nginx:alpine\n    container_name: chat-proxy\n    volumes:\n      - ./proxy.conf:/etc/nginx/conf.d/default.conf\n    ports:\n      - 8080:80\n    depends_on:\n      - web\n"
  },
  {
    "path": "lerna.json",
    "content": "{\n  \"packages\": [\n    \"packages/*\"\n  ],\n  \"version\": \"independent\"\n}\n"
  },
  {
    "path": "mongo-init.sh",
    "content": "echo \"Creating $DB_USERNAME user on $MONGO_INITDB_DATABASE database\"\n\nmongo ${MONGO_INITDB_DATABASE} \\\n        -u ${MONGO_INITDB_ROOT_USERNAME} \\\n        -p ${MONGO_INITDB_ROOT_PASSWORD} \\\n        --authenticationDatabase admin \\\n        --eval \"db.createUser({user: '$DB_USERNAME', pwd: '$DB_PASSWORD', roles:['readWrite']});\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"root\",\n  \"private\": true,\n  \"license\": \"MIT\",\n  \"scripts\": {\n    \"up\": \"mkdir -p data && docker-compose -f docker-compose.dev.yml up -d\",\n    \"postup\": \"wait-on data && npm run dev\",\n    \"dev\": \"lerna run --parallel dev\",\n    \"stop\": \"docker-compose -f docker-compose.dev.yml stop\",\n    \"down\": \"docker-compose -f docker-compose.dev.yml down && rm -rf data/*\",\n    \"commit\": \"git-cz\",\n    \"lint\": \"eslint '**/*.{js,ts,tsx}'\",\n    \"lint:fix\": \"npm run lint -- --fix\",\n    \"test\": \"lerna run --parallel test\"\n  },\n  \"devDependencies\": {\n    \"@typescript-eslint/eslint-plugin\": \"^2.9.0\",\n    \"@typescript-eslint/parser\": \"^2.9.0\",\n    \"commitizen\": \"^4.0.3\",\n    \"cz-conventional-changelog\": \"^3.0.2\",\n    \"eslint\": \"^6.7.2\",\n    \"eslint-config-prettier\": \"^6.7.0\",\n    \"eslint-plugin-import\": \"^2.18.2\",\n    \"eslint-plugin-jest\": \"^23.1.0\",\n    \"eslint-plugin-node\": \"^10.0.0\",\n    \"eslint-plugin-prettier\": \"^3.1.1\",\n    \"eslint-plugin-promise\": \"^4.2.1\",\n    \"eslint-plugin-react\": \"^7.17.0\",\n    \"eslint-plugin-react-hooks\": \"^2.3.0\",\n    \"lerna\": \"^3.19.0\",\n    \"prettier\": \"^1.19.1\",\n    \"typescript\": \"^3.7.2\",\n    \"wait-on\": \"^3.3.0\"\n  },\n  \"config\": {\n    \"commitizen\": {\n      \"path\": \"cz-conventional-changelog\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/api/.dockerignore",
    "content": "dist\n\nnode_modules\n\n.dockerignore\n\nDockerfile\n\n*.log\n\nreadme.md\n"
  },
  {
    "path": "packages/api/Dockerfile",
    "content": "FROM node:12-alpine AS builder\n\nWORKDIR /usr/src/app\n\nCOPY package*.json ./\n\nRUN npm install --only=development\n\nCOPY . .\n\nRUN npm run build\n\nFROM node:12-alpine\n\nWORKDIR /usr/src/app\n\nCOPY package*.json ./\n\nRUN npm install --only=production\n\nCOPY --from=builder /usr/src/app/dist ./\n\nUSER node\n\nENV NODE_ENV=production\n\nCMD [\"node\", \"-r\", \"source-map-support/register\", \"index.js\"]\n"
  },
  {
    "path": "packages/api/jest.config.js",
    "content": "module.exports = {\n  preset: 'ts-jest',\n  testEnvironment: 'node',\n  setupFilesAfterEnv: ['./test/setup.ts']\n}\n"
  },
  {
    "path": "packages/api/package.json",
    "content": "{\n  \"name\": \"@graphql-chat/api\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"dist/index.js\",\n  \"description\": \"Real-time GraphQL chat API\",\n  \"author\": \"Alex\",\n  \"license\": \"MIT\",\n  \"scripts\": {\n    \"predev\": \"rimraf dist\",\n    \"dev\": \"ts-node-dev --transpileOnly --no-notify src\",\n    \"prebuild\": \"rimraf dist\",\n    \"build\": \"tsc -p tsconfig.prod.json\",\n    \"test\": \"jest\"\n  },\n  \"homepage\": \"https://github.com/alex996/graphql-chat#readme\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/alex996/graphql-chat.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/alex996/graphql-chat/issues\"\n  },\n  \"dependencies\": {\n    \"@hapi/joi\": \"^16.1.8\",\n    \"apollo-server-express\": \"^2.9.12\",\n    \"bcryptjs\": \"^2.4.3\",\n    \"connect-redis\": \"^4.0.3\",\n    \"express\": \"^4.17.1\",\n    \"express-session\": \"^1.17.0\",\n    \"graphql\": \"^14.5.8\",\n    \"graphql-fields\": \"^2.0.3\",\n    \"ioredis\": \"^4.14.1\",\n    \"mongoose\": \"^5.7.13\",\n    \"source-map-support\": \"^0.5.15\"\n  },\n  \"devDependencies\": {\n    \"@types/bcryptjs\": \"^2.4.2\",\n    \"@types/connect-redis\": \"^0.0.13\",\n    \"@types/express\": \"^4.17.2\",\n    \"@types/express-session\": \"^1.15.16\",\n    \"@types/graphql\": \"^14.5.0\",\n    \"@types/graphql-fields\": \"^1.3.2\",\n    \"@types/hapi__joi\": \"^16.0.2\",\n    \"@types/ioredis\": \"^4.0.20\",\n    \"@types/jest\": \"^24.0.23\",\n    \"@types/mongodb\": \"^3.3.11\",\n    \"@types/mongoose\": \"^5.5.32\",\n    \"@types/node\": \"^12.12.14\",\n    \"@types/supertest\": \"^2.0.8\",\n    \"graphql-redis-subscriptions\": \"^2.1.1\",\n    \"jest\": \"^24.9.0\",\n    \"mongodb-memory-server\": \"^6.0.1\",\n    \"rimraf\": \"^3.0.0\",\n    \"supertest\": \"^4.0.2\",\n    \"ts-jest\": \"^24.2.0\",\n    \"ts-node-dev\": \"^1.0.0-pre.43\",\n    \"typescript\": \"^3.7.2\"\n  }\n}\n"
  },
  {
    "path": "packages/api/readme.md",
    "content": "# @graphql-chat/api\n\nReal-time GraphQL chat API.\n"
  },
  {
    "path": "packages/api/src/app.ts",
    "content": "import express from 'express'\nimport session from 'express-session'\nimport { ApolloServer } from 'apollo-server-express'\nimport typeDefs from './typeDefs'\nimport resolvers from './resolvers'\nimport schemaDirectives from './directives'\nimport { SESS_OPTIONS, APOLLO_OPTIONS } from './config'\nimport { Request, Response } from './types'\nimport { ensureSignedIn } from './auth'\n\nconst createApp = (store?: session.Store) => {\n  const app = express()\n\n  const sessionHandler = session({\n    store,\n    ...SESS_OPTIONS\n  })\n\n  app.use(sessionHandler)\n\n  const server = new ApolloServer({\n    ...APOLLO_OPTIONS,\n    typeDefs,\n    resolvers,\n    schemaDirectives,\n    context: ({ req, res, connection }) =>\n      connection ? connection.context : { req, res },\n    subscriptions: {\n      onConnect: async (connectionParams, webSocket, { request }) => {\n        const req = await new Promise(resolve => {\n          sessionHandler(request as Request, {} as Response, () => {\n            // Directives are ignored in WS; need to auth explicitly\n            ensureSignedIn(request as Request)\n\n            resolve(request)\n          })\n        })\n\n        return { req }\n      }\n    }\n  })\n\n  server.applyMiddleware({ app, cors: false })\n\n  return { app, server }\n}\n\nexport default createApp\n"
  },
  {
    "path": "packages/api/src/auth.ts",
    "content": "import { AuthenticationError } from 'apollo-server-express'\nimport { User } from './models'\nimport { SESS_NAME } from './config'\nimport { Request, Response, UserDocument } from './types'\n\nexport const attemptSignIn = async (\n  { email, password }: { email: string; password: string },\n  fields: string\n): Promise<UserDocument> => {\n  const user = await User.findOne({ email }).select(`${fields} password`)\n\n  if (!user || !(await user.matchesPassword(password))) {\n    throw new AuthenticationError(\n      'Incorrect email or password. Please try again.'\n    )\n  }\n\n  return user\n}\n\nconst signedIn = (req: Request): boolean => req.session.userId\n\nexport const ensureSignedIn = (req: Request): void => {\n  if (!signedIn(req)) {\n    throw new AuthenticationError('You must be signed in.')\n  }\n}\n\nexport const ensureSignedOut = (req: Request): void => {\n  if (signedIn(req)) {\n    throw new AuthenticationError('You are already signed in.')\n  }\n}\n\nexport const signOut = (req: Request, res: Response): Promise<boolean> =>\n  new Promise((resolve, reject) => {\n    req.session.destroy(err => {\n      if (err) reject(err)\n\n      res.clearCookie(SESS_NAME)\n\n      resolve(true)\n    })\n  })\n"
  },
  {
    "path": "packages/api/src/config.ts",
    "content": "const ONE_DAY = 1000 * 60 * 60 * 24\n\nexport const {\n  HTTP_PORT = 3000,\n  NODE_ENV = 'development',\n\n  DB_USERNAME = 'admin',\n  DB_PASSWORD = 'secret',\n  DB_HOST = 'localhost',\n  DB_PORT = 27017,\n  DB_NAME = 'chat',\n\n  SESS_NAME = 'sid',\n  SESS_SECRET = 'ssh!secret!',\n  SESS_LIFETIME = ONE_DAY,\n\n  REDIS_HOST = 'localhost',\n  REDIS_PORT = 6379,\n  REDIS_PASSWORD = 'secret'\n} = process.env\n\nexport const IN_PROD = NODE_ENV === 'production'\n\n// Password URL encoded to escape special characters\nexport const DB_URI = `mongodb://${DB_USERNAME}:${encodeURIComponent(\n  DB_PASSWORD\n)}@${DB_HOST}:${DB_PORT}/${DB_NAME}`\n\nexport const DB_OPTIONS = { useNewUrlParser: true, useUnifiedTopology: true }\n\nexport const REDIS_OPTIONS = {\n  host: REDIS_HOST,\n  port: +REDIS_PORT,\n  password: REDIS_PASSWORD\n  // TODO: retry_strategy\n}\n\nexport const SESS_OPTIONS = {\n  name: SESS_NAME,\n  secret: SESS_SECRET,\n  resave: true,\n  rolling: true,\n  saveUninitialized: false,\n  cookie: {\n    maxAge: +SESS_LIFETIME,\n    sameSite: true,\n    secure: IN_PROD\n  }\n}\n\nexport const APOLLO_OPTIONS = {\n  playground: IN_PROD\n    ? false\n    : {\n        settings: {\n          'request.credentials': 'include'\n        }\n      }\n}\n"
  },
  {
    "path": "packages/api/src/directives/auth.ts",
    "content": "import { SchemaDirectiveVisitor } from 'apollo-server-express'\nimport { GraphQLField, defaultFieldResolver } from 'graphql'\nimport { ensureSignedIn } from '../auth'\n\nclass AuthDirective extends SchemaDirectiveVisitor {\n  public visitFieldDefinition(field: GraphQLField<any, any>) {\n    const { resolve = defaultFieldResolver } = field\n\n    field.resolve = function(...args) {\n      const [, , context] = args\n\n      ensureSignedIn(context.req)\n\n      return resolve.apply(this, args)\n    }\n  }\n}\n\nexport default AuthDirective\n"
  },
  {
    "path": "packages/api/src/directives/guest.ts",
    "content": "import { SchemaDirectiveVisitor } from 'apollo-server-express'\nimport { GraphQLField, defaultFieldResolver } from 'graphql'\nimport { ensureSignedOut } from '../auth'\n\nclass GuestDirective extends SchemaDirectiveVisitor {\n  public visitFieldDefinition(field: GraphQLField<any, any>) {\n    const { resolve = defaultFieldResolver } = field\n\n    field.resolve = function(...args) {\n      const [, , context] = args\n\n      ensureSignedOut(context.req)\n\n      return resolve.apply(this, args)\n    }\n  }\n}\n\nexport default GuestDirective\n"
  },
  {
    "path": "packages/api/src/directives/index.ts",
    "content": "import AuthDirective from './auth'\nimport GuestDirective from './guest'\n\nexport default {\n  auth: AuthDirective,\n  guest: GuestDirective\n}\n"
  },
  {
    "path": "packages/api/src/index.ts",
    "content": "import mongoose from 'mongoose'\nimport connectRedis from 'connect-redis'\nimport session from 'express-session'\nimport Redis from 'ioredis'\nimport http from 'http'\nimport createApp from './app'\nimport { DB_URI, DB_OPTIONS, REDIS_OPTIONS, HTTP_PORT } from './config'\n//\n;(async () => {\n  try {\n    await mongoose.connect(DB_URI, DB_OPTIONS)\n\n    const RedisStore = connectRedis(session)\n\n    const store = new RedisStore({\n      client: new Redis(REDIS_OPTIONS)\n    })\n\n    const { app, server } = createApp(store)\n\n    const httpServer = http.createServer(app)\n    server.installSubscriptionHandlers(httpServer)\n\n    httpServer.listen(HTTP_PORT, () => {\n      console.log(`http://localhost:${HTTP_PORT}${server.graphqlPath}`)\n      console.log(`ws://localhost:${HTTP_PORT}${server.subscriptionsPath}`)\n    })\n  } catch (e) {\n    console.error(e)\n  }\n})()\n"
  },
  {
    "path": "packages/api/src/models/chat.ts",
    "content": "/* eslint-disable @typescript-eslint/no-use-before-define */\nimport { model, Schema } from 'mongoose'\nimport { ChatDocument } from '../types'\nimport { User } from './'\n\nconst { ObjectId } = Schema.Types\n\nconst chatSchema = new Schema(\n  {\n    title: String,\n    users: {\n      type: [\n        {\n          type: ObjectId,\n          ref: 'User'\n        }\n      ],\n      validate: [\n        {\n          // TODO: both run in parallel, make sequential\n          validator: async (userIds: string[]): Promise<boolean> =>\n            (await User.where('_id')\n              .in(userIds)\n              .countDocuments()) === userIds.length,\n          message: 'One or more User IDs are invalid.'\n        },\n        {\n          validator: async (userIds: string[]): Promise<boolean> =>\n            !(await Chat.exists({ users: userIds })),\n          message: 'Chat with given User IDs already exists.'\n        }\n      ]\n    },\n    lastMessage: {\n      type: ObjectId,\n      ref: 'Message'\n    }\n  },\n  {\n    timestamps: true\n  }\n)\n\nconst Chat = model<ChatDocument>('Chat', chatSchema)\n\nexport default Chat\n"
  },
  {
    "path": "packages/api/src/models/index.ts",
    "content": "export { default as Chat } from './chat'\n\nexport { default as User } from './user'\n\nexport { default as Message } from './message'\n"
  },
  {
    "path": "packages/api/src/models/message.ts",
    "content": "import mongoose, { Schema } from 'mongoose'\nimport { MessageDocument } from '../types'\n\nconst { ObjectId } = Schema.Types\n\nconst messageSchema = new Schema(\n  {\n    body: String,\n    sender: {\n      type: ObjectId,\n      ref: 'User'\n    },\n    chat: {\n      type: ObjectId,\n      ref: 'Chat'\n    }\n  },\n  {\n    timestamps: true\n  }\n)\n\nexport default mongoose.model<MessageDocument>('Message', messageSchema)\n"
  },
  {
    "path": "packages/api/src/models/user.ts",
    "content": "/* eslint-disable @typescript-eslint/no-use-before-define */\nimport { model, Schema } from 'mongoose'\nimport { hash, compare } from 'bcryptjs'\nimport { UserDocument, UserModel } from '../types'\n\nconst userSchema = new Schema(\n  {\n    username: {\n      type: String,\n      validate: [\n        async (username: string): Promise<boolean> =>\n          !(await User.exists({ username })),\n        'Username is already taken.'\n      ]\n    },\n    email: {\n      type: String,\n      validate: [\n        async (email: string): Promise<boolean> =>\n          !(await User.exists({ email })),\n        'Email is already taken.'\n      ]\n    },\n    name: String,\n    password: String,\n    chats: [\n      {\n        type: Schema.Types.ObjectId,\n        ref: 'Chat'\n      }\n    ]\n  },\n  {\n    timestamps: true\n  }\n)\n\nuserSchema.pre('save', async function(this: UserDocument) {\n  if (this.isModified('password')) {\n    this.password = await User.hash(this.password)\n  }\n})\n\nuserSchema.statics.hash = (password: string): Promise<string> =>\n  hash(password, 10)\n\nuserSchema.methods.matchesPassword = function(\n  this: UserDocument,\n  password: string\n): Promise<boolean> {\n  return compare(password, this.password)\n}\n\nconst User = model<UserDocument, UserModel>('User', userSchema)\n\nexport default User\n"
  },
  {
    "path": "packages/api/src/pubsub.ts",
    "content": "import { RedisPubSub } from 'graphql-redis-subscriptions'\nimport Redis from 'ioredis'\nimport { REDIS_OPTIONS } from './config'\n\nconst pubsub = new RedisPubSub({\n  publisher: new Redis(REDIS_OPTIONS),\n  subscriber: new Redis(REDIS_OPTIONS)\n})\n\nexport default pubsub\n"
  },
  {
    "path": "packages/api/src/resolvers/__fixtures__/index.ts",
    "content": "// TODO: Consider using faker\n\nexport const alex = {\n  name: 'Alex',\n  username: 'alex',\n  email: 'alex@gmail.com',\n  password: 'Secret12'\n}\n\nexport const max = {\n  name: 'Max',\n  username: 'max',\n  email: 'max@gmail.com',\n  password: 'Password12'\n}\n\nexport const users = ['Mark', 'Jane', 'Rick'].map(name => ({\n  name: name,\n  username: name,\n  email: `${name}@example.com`,\n  password: 'Password12'\n}))\n"
  },
  {
    "path": "packages/api/src/resolvers/__tests__/chat.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-non-null-assertion */\nimport { User, Chat, Message } from '../../models'\nimport { UserDocument } from '../../types'\nimport { alex, max } from '../__fixtures__'\n\nconst { name, email, password } = alex\n\nlet a: UserDocument, m: UserDocument, cookie: string\n\nbeforeEach(async () => {\n  a = await User.create(alex)\n  m = await User.create(max)\n  ;({\n    header: {\n      'set-cookie': [cookie]\n    }\n  } = await global.signIn(email, password))\n})\n\nafterEach(async () => {\n  await User.deleteMany({})\n  await Chat.deleteMany({})\n})\n\ndescribe('Mutation', () => {\n  describe('startChat', () => {\n    it('should create a chat', async () => {\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n          mutation {\n            startChat(userIds: [\"${m.id}\"]) {\n              id\n              title\n            }\n          }\n        `\n        })\n\n      // Then\n      const title = `${name}, ${m.name}`\n      const chat = await Chat.findOne({ title }).select('_id')\n\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { startChat: { id: chat!.id, title } } })\n    })\n  })\n})\n\ndescribe('Chat', () => {\n  describe('messages', () => {\n    it('should return chat messages', async () => {\n      // Given\n      const chat = await Chat.create({\n        users: [a.id, m.id]\n      })\n      await a.updateOne({ $push: { chats: chat } })\n      const messages = await Message.insertMany([\n        { body: 'Hi', sender: a, chat },\n        { body: \"What's up\", sender: m, chat }\n      ])\n\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            {\n              me {\n                chats {\n                  id\n                  messages {\n                    body\n                  }\n                }\n              }\n            }\n          `\n        })\n\n      // Then\n      const results = messages.map(m => ({ body: m.body }))\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({\n        data: { me: { chats: [{ id: chat.id, messages: results }] } }\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "packages/api/src/resolvers/__tests__/user.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-non-null-assertion */\nimport { User, Chat } from '../../models'\nimport { alex, max, users } from '../__fixtures__'\n\nconst { name, username, email, password } = alex\n\nafterEach(async () => {\n  await User.deleteMany({})\n})\n\ndescribe('Mutation', () => {\n  describe('signUp', () => {\n    it('should return a new user and set a cookie', async () => {\n      // When\n      const res = await global.graphql().send({\n        query: `\n          mutation {\n            signUp(\n              name: \"${name}\",\n              username: \"${username}\",\n              email: \"${email}\",\n              password: \"${password}\"\n            ) {\n              id\n            }\n          }\n        `\n      })\n\n      // Then\n      const user = await User.findOne({ email }).select('_id')\n\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { signUp: { id: user!.id } } })\n      expect(res.header['set-cookie'][0]).toContain('sid=s%3A')\n    })\n  })\n\n  describe('signIn', () => {\n    it('should return an existing user and set a cookie', async () => {\n      // Given\n      const { id } = await User.create(alex)\n\n      // When\n      const res = await global.signIn(email, password)\n\n      // Then\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { signIn: { id } } })\n      expect(res.header['set-cookie'][0]).toContain('sid=s%3A')\n    })\n  })\n\n  describe('signOut', () => {\n    it('should return true and clear the cookie', async () => {\n      // Given\n      await User.create(alex)\n      const {\n        header: {\n          'set-cookie': [cookie]\n        }\n      } = await global.signIn(email, password)\n\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            mutation {\n              signOut\n            }\n          `\n        })\n\n      // Then\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { signOut: true } })\n      expect(res.header['set-cookie'][0]).toContain('sid=;')\n    })\n  })\n})\n\ndescribe('Query', () => {\n  let id: string, cookie: string\n\n  beforeEach(async () => {\n    ;({ id } = await User.create(alex))\n    ;({\n      header: {\n        'set-cookie': [cookie]\n      }\n    } = await global.signIn(email, password))\n  })\n\n  describe('me', () => {\n    it('should return the signed-in user', async () => {\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            {\n              me {\n                id\n                email\n                username\n              }\n            }\n          `\n        })\n\n      // Then\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { me: { id, email, username } } })\n    })\n  })\n\n  describe('user', () => {\n    it('should return the user with a given id', async () => {\n      // Given\n      const { id, username, email } = await User.create(max)\n\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            {\n              user(id: \"${id}\") {\n                id\n                email\n                username\n              }\n            }\n          `\n        })\n\n      // Then\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { user: { id, email, username } } })\n    })\n  })\n\n  describe('users', () => {\n    it('should return existing users', async () => {\n      // Given\n      const docs = await User.insertMany(users)\n\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            {\n              users {\n                id\n                email\n                username\n              }\n            }\n          `\n        })\n\n      // Then\n      const results = [\n        { id, email, username },\n        ...docs.map(({ id, email, username }) => ({\n          id,\n          email,\n          username\n        }))\n      ]\n\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({ data: { users: results } })\n    })\n  })\n})\n\ndescribe('User', () => {\n  describe('chats', () => {\n    it('should return user chats', async () => {\n      // Given\n      const [a, { id: m }] = await Promise.all([\n        User.create(alex),\n        User.create(max)\n      ])\n      const { id } = await Chat.create({ users: [a.id, m] })\n      await a.updateOne({ $push: { chats: id } })\n\n      const {\n        header: {\n          'set-cookie': [cookie]\n        }\n      } = await global.signIn(email, password)\n\n      // When\n      const res = await global\n        .graphql()\n        .set('Cookie', cookie)\n        .send({\n          query: `\n            {\n              me {\n                chats {\n                  id\n                  title\n                }\n              }\n            }\n          `\n        })\n\n      // Then\n      expect(res.status).toBe(200)\n      expect(res.body).toEqual({\n        data: { me: { chats: [{ id, title: `${name}, ${max.name}` }] } }\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "packages/api/src/resolvers/chat.ts",
    "content": "import {\n  IResolvers,\n  UserInputError,\n  ForbiddenError\n} from 'apollo-server-express'\nimport { startChat, inviteUsers } from '../validators'\nimport { User, Chat, Message } from '../models'\nimport { Request, ChatDocument, UserDocument, MessageDocument } from '../types'\nimport { fields } from '../utils'\n\nconst resolvers: IResolvers = {\n  Mutation: {\n    startChat: async (\n      root,\n      args: { title: string; userIds: [string] },\n      { req }: { req: Request }\n    ): Promise<ChatDocument> => {\n      const { userId } = req.session\n      const { title, userIds } = args\n\n      await startChat(userId).validateAsync(args, { abortEarly: false })\n\n      userIds.push(userId)\n\n      const chat = await Chat.create({ title, users: userIds })\n\n      await User.updateMany(\n        { _id: { $in: userIds } },\n        {\n          $push: { chats: chat }\n        }\n      )\n\n      return chat\n    },\n    inviteUsers: async (\n      root,\n      args: { chatId: string; userIds: [string] },\n      { req }: { req: Request }\n    ) => {\n      const { userId } = req.session\n      const { chatId, userIds } = args\n\n      await inviteUsers(userId).validateAsync(args, { abortEarly: false })\n\n      const chat = await Chat.findById(chatId)\n\n      if (!chat) {\n        throw new UserInputError('Chat was not found')\n      }\n\n      if (!chat.users.includes(userId)) {\n        throw new ForbiddenError(\n          'You are not a member of this chat. Please ask for an invite.'\n        )\n      }\n\n      const idsFound = await User.where('_id')\n        .in(userIds)\n        .countDocuments()\n\n      if (idsFound !== userIds.length) {\n        throw new UserInputError('One or more User IDs are invalid.')\n      }\n\n      console.log(chat.users, chat.users.includes(userId), userId)\n\n      return\n    }\n  },\n  Chat: {\n    messages: (\n      chat: ChatDocument,\n      args,\n      ctx,\n      info\n    ): Promise<MessageDocument[]> => {\n      // TODO: pagination\n      return Message.find({ chat: chat.id }, fields(info)).exec()\n    },\n    users: async (\n      chat: ChatDocument,\n      args,\n      ctx,\n      info\n    ): Promise<UserDocument[]> => {\n      return (await chat.populate('users', fields(info)).execPopulate()).users\n    },\n    lastMessage: async (\n      chat: ChatDocument,\n      args,\n      ctx,\n      info\n    ): Promise<MessageDocument> => {\n      return (await chat.populate('lastMessage', fields(info)).execPopulate())\n        .lastMessage\n    }\n  }\n}\n\nexport default resolvers\n"
  },
  {
    "path": "packages/api/src/resolvers/index.ts",
    "content": "import chat from './chat'\nimport message from './message'\nimport user from './user'\n\nexport default [chat, message, user]\n"
  },
  {
    "path": "packages/api/src/resolvers/message.ts",
    "content": "import { Types } from 'mongoose'\nimport {\n  IResolvers,\n  UserInputError,\n  ForbiddenError,\n  withFilter\n} from 'apollo-server-express'\nimport { Request, MessageDocument, UserDocument } from '../types'\nimport { sendMessage } from '../validators'\nimport { Chat, Message } from '../models'\nimport { fields, hasSubfields } from '../utils'\nimport pubsub from '../pubsub'\n\nconst MESSAGE_SENT = 'MESSAGE_SENT'\n\nconst resolvers: IResolvers = {\n  Mutation: {\n    sendMessage: async (\n      root,\n      args: { chatId: string; body: string },\n      { req }: { req: Request }\n    ): Promise<MessageDocument> => {\n      await sendMessage.validateAsync(args, { abortEarly: false })\n\n      const { userId } = req.session\n      const { chatId, body } = args\n\n      const chat = await Chat.findById(chatId).select('users')\n\n      if (!chat) {\n        throw new UserInputError('Chat was not found.')\n      } else if (!chat.users.some((id: Types.ObjectId) => id.equals(userId))) {\n        throw new ForbiddenError(\n          'Cannot join the chat. Please ask for an invite.'\n        )\n      }\n\n      const message = await Message.create({\n        body,\n        sender: userId,\n        chat: chatId\n      })\n\n      pubsub.publish(MESSAGE_SENT, { messageSent: message, users: chat.users })\n\n      chat.lastMessage = message\n      await chat.save()\n\n      return message\n    }\n  },\n  Subscription: {\n    messageSent: {\n      resolve: (\n        { messageSent }: { messageSent: MessageDocument },\n        args,\n        ctx,\n        info\n      ) => {\n        return hasSubfields(info)\n          ? Message.findById(messageSent._id, fields(info))\n          : messageSent\n      },\n      subscribe: withFilter(\n        () => pubsub.asyncIterator(MESSAGE_SENT),\n        async (\n          {\n            messageSent,\n            users\n          }: { messageSent: MessageDocument; users: [string] },\n          { chatId }: { chatId: string },\n          { req }: { req: Request }\n        ) => {\n          return (\n            messageSent.chat === chatId && users.includes(req.session.userId)\n          )\n        }\n      )\n    }\n  },\n  Message: {\n    sender: async (\n      message: MessageDocument,\n      args,\n      ctx,\n      info\n    ): Promise<UserDocument> => {\n      return (await message.populate('sender', fields(info)).execPopulate())\n        .sender\n    }\n  }\n}\n\nexport default resolvers\n"
  },
  {
    "path": "packages/api/src/resolvers/user.ts",
    "content": "import { IResolvers } from 'apollo-server-express'\nimport { Request, Response, UserDocument, ChatDocument } from '../types'\nimport { signUp, signIn, objectId } from '../validators'\nimport { attemptSignIn, signOut } from '../auth'\nimport { User } from '../models'\nimport { fields } from '../utils'\n\nconst resolvers: IResolvers = {\n  Query: {\n    me: (\n      root,\n      args,\n      { req }: { req: Request },\n      info\n    ): Promise<UserDocument | null> => {\n      return User.findById(req.session.userId, fields(info)).exec()\n    },\n    users: (root, args, ctx, info): Promise<UserDocument[]> => {\n      // TODO: pagination\n      return User.find({}, fields(info)).exec()\n    },\n    user: async (\n      root,\n      args: { id: string },\n      ctx,\n      info\n    ): Promise<UserDocument | null> => {\n      await objectId.validateAsync(args)\n\n      return User.findById(args.id, fields(info))\n    }\n  },\n  Mutation: {\n    signUp: async (\n      root,\n      args: { email: string; username: string; name: string; password: string },\n      { req }: { req: Request }\n    ): Promise<UserDocument> => {\n      await signUp.validateAsync(args, { abortEarly: false })\n\n      const user = await User.create(args)\n\n      req.session.userId = user.id\n\n      return user\n    },\n    signIn: async (\n      root,\n      args: { email: string; password: string },\n      { req }: { req: Request },\n      info\n    ): Promise<UserDocument> => {\n      await signIn.validateAsync(args, { abortEarly: false })\n\n      const user = await attemptSignIn(args, fields(info))\n\n      req.session.userId = user.id\n\n      return user\n    },\n    signOut: (\n      root,\n      args,\n      { req, res }: { req: Request; res: Response }\n    ): Promise<boolean> => {\n      return signOut(req, res)\n    }\n  },\n  User: {\n    chats: async (\n      user: UserDocument,\n      args,\n      { req }: { req: Request },\n      info\n    ): Promise<ChatDocument[]> => {\n      if (user.id !== req.session.userId) {\n        return []\n      }\n\n      await user\n        .populate({\n          // TODO: paginate\n          path: 'chats',\n          select: fields(info)\n        })\n        .execPopulate()\n      return user.chats\n    }\n  }\n}\n\nexport default resolvers\n"
  },
  {
    "path": "packages/api/src/typeDefs/chat.ts",
    "content": "import { gql } from 'apollo-server-express'\n\nexport default gql`\n  extend type Mutation {\n    startChat(title: String, userIds: [ID!]!): Chat @auth\n    inviteUsers(chatId: ID!, userIds: [ID!]!): Chat @auth\n  }\n\n  type Chat {\n    id: ID!\n    title: String\n    users: [User!]!\n    messages: [Message!]!\n    lastMessage: Message\n    createdAt: String!\n    updatedAt: String!\n  }\n`\n"
  },
  {
    "path": "packages/api/src/typeDefs/index.ts",
    "content": "import chat from './chat'\nimport message from './message'\nimport root from './root'\nimport user from './user'\n\nexport default [root, chat, message, user]\n"
  },
  {
    "path": "packages/api/src/typeDefs/message.ts",
    "content": "import { gql } from 'apollo-server-express'\n\nexport default gql`\n  extend type Mutation {\n    sendMessage(chatId: ID!, body: String!): Message @auth\n  }\n\n  extend type Subscription {\n    messageSent(chatId: ID!): Message @auth\n  }\n\n  type Message {\n    id: ID!\n    body: String!\n    sender: User!\n    createdAt: String!\n    updatedAt: String!\n  }\n`\n"
  },
  {
    "path": "packages/api/src/typeDefs/root.ts",
    "content": "import { gql } from 'apollo-server-express'\n\nexport default gql`\n  directive @auth on FIELD_DEFINITION\n\n  directive @guest on FIELD_DEFINITION\n\n  type Query {\n    _: String\n  }\n\n  type Mutation {\n    _: String\n  }\n\n  type Subscription {\n    _: String\n  }\n`\n"
  },
  {
    "path": "packages/api/src/typeDefs/user.ts",
    "content": "import { gql } from 'apollo-server-express'\n\nexport default gql`\n  extend type Query {\n    me: User @auth\n    user(id: ID!): User @auth\n    users: [User!]! @auth\n  }\n\n  extend type Mutation {\n    signUp(\n      email: String!\n      username: String!\n      name: String!\n      password: String!\n    ): User @guest\n    signIn(email: String!, password: String!): User @guest\n    signOut: Boolean @auth\n  }\n\n  type User {\n    id: ID!\n    email: String!\n    username: String!\n    name: String!\n    chats: [Chat!]!\n    createdAt: String!\n    updatedAt: String!\n  }\n`\n"
  },
  {
    "path": "packages/api/src/types/chat.d.ts",
    "content": "import { Document } from 'mongoose'\nimport { UserDocument, MessageDocument } from './'\n\nexport interface ChatDocument extends Document {\n  title: string\n  users: [UserDocument['_id']]\n  lastMessage: MessageDocument['_id']\n}\n"
  },
  {
    "path": "packages/api/src/types/express.d.ts",
    "content": "import { Request as ExpressRequest, Response as ExpressResponse } from 'express'\n\nexport type Request = ExpressRequest & {\n  session: Express.Session\n}\n\nexport type Response = ExpressResponse\n"
  },
  {
    "path": "packages/api/src/types/index.d.ts",
    "content": "export * from './chat'\n\nexport * from './express'\n\nexport * from './message'\n\nexport * from './user'\n"
  },
  {
    "path": "packages/api/src/types/message.d.ts",
    "content": "import { Document } from 'mongoose'\nimport { UserDocument, ChatDocument } from './'\n\nexport interface MessageDocument extends Document {\n  body: string\n  sender: UserDocument['_id']\n  chat: ChatDocument['_id']\n}\n"
  },
  {
    "path": "packages/api/src/types/user.d.ts",
    "content": "import { Document, Model } from 'mongoose'\nimport { ChatDocument } from './'\n\nexport interface UserDocument extends Document {\n  name: string\n  email: string\n  username: string\n  password: string\n  chats: [ChatDocument['_id']]\n  matchesPassword: (password: string) => Promise<boolean>\n}\n\nexport interface UserModel extends Model<UserDocument> {\n  hash: (password: string) => Promise<string>\n}\n"
  },
  {
    "path": "packages/api/src/utils/graphql.ts",
    "content": "import graphqlFields from 'graphql-fields'\nimport { GraphQLResolveInfo } from 'graphql'\n\ninterface FieldsMap {\n  [field: string]: {} | FieldsMap\n}\n\nconst fieldsMap = (info: GraphQLResolveInfo): FieldsMap => {\n  return graphqlFields(info, {}, { excludedFields: ['__typename'] })\n}\n\nconst isEmpty = (obj: object): boolean => {\n  return Object.getOwnPropertyNames(obj).length === 0\n}\n\n// Whether the info object contains any non-flat (i.e. nested) fields\nexport const hasSubfields = (info: GraphQLResolveInfo): boolean => {\n  return Object.values(fieldsMap(info)).some(\n    (subfields: object) => !isEmpty(subfields)\n  )\n}\n\n// Space-separated fields as requested in the info object\nexport const fields = (info: GraphQLResolveInfo): string => {\n  return Object.keys(fieldsMap(info)).join(' ')\n}\n"
  },
  {
    "path": "packages/api/src/utils/index.ts",
    "content": "export * from './graphql'\n"
  },
  {
    "path": "packages/api/src/validators/chat.ts",
    "content": "import Joi from './joi'\n\nconst title = Joi.string()\n  .min(6)\n  .max(30)\n  .label('Title')\n\nconst userIds = (userId: string) =>\n  Joi.array()\n    .min(1)\n    .max(100)\n    .unique()\n    .items(\n      Joi.objectId()\n        .not(userId)\n        .label('User ID')\n    )\n    .label('User IDs')\n\nexport const startChat = (userId: string) =>\n  Joi.object().keys({\n    title,\n    userIds: userIds(userId)\n  })\n\nexport const inviteUsers = (userId: string) =>\n  Joi.object().keys({\n    chatId: Joi.objectId().required(),\n    userIds: userIds(userId)\n  })\n"
  },
  {
    "path": "packages/api/src/validators/index.ts",
    "content": "export * from './chat'\n\nexport * from './message'\n\nexport * from './user'\n\nexport * from './utils'\n"
  },
  {
    "path": "packages/api/src/validators/joi.ts",
    "content": "import Joi, { ExtensionFactory } from '@hapi/joi'\nimport mongoose from 'mongoose'\n\nconst objectId: ExtensionFactory = joi => ({\n  type: 'objectId',\n  base: joi.string(),\n  messages: {\n    objectId: '\"{{#label}}\" must be a valid Object ID'\n  },\n  validate(value, helpers) {\n    if (!mongoose.Types.ObjectId.isValid(value)) {\n      return { value, errors: helpers.error('objectId') }\n    }\n  }\n})\n\nexport default Joi.extend(objectId)\n"
  },
  {
    "path": "packages/api/src/validators/message.ts",
    "content": "import Joi from './joi'\n\nexport const sendMessage = Joi.object().keys({\n  chatId: Joi.objectId()\n    .required()\n    .label('Chat ID'),\n  body: Joi.string()\n    .required()\n    .max(4_000) // TODO: Truncate into multiple msgs\n    .label('Body')\n})\n"
  },
  {
    "path": "packages/api/src/validators/user.ts",
    "content": "import Joi from '@hapi/joi'\n\nconst email = Joi.string()\n  .email()\n  .min(8)\n  .max(254)\n  .trim()\n  .lowercase()\n  .required()\n  .label('Email')\n\nconst username = Joi.string()\n  .alphanum()\n  .min(3)\n  .max(50)\n  .trim()\n  .required()\n  .label('Username')\n\nconst name = Joi.string()\n  .max(100)\n  .trim()\n  .required()\n  .label('Name')\n\nconst password = Joi.string()\n  .min(8)\n  .max(100)\n  .regex(/^(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d).*$/)\n  .message(\n    'must have at least one lowercase letter, one uppercase letter, and one digit.'\n  )\n  .required()\n  .label('Password')\n\nexport const signUp = Joi.object().keys({\n  email,\n  username,\n  name,\n  password\n})\n\nexport const signIn = Joi.object().keys({\n  email,\n  password\n})\n"
  },
  {
    "path": "packages/api/src/validators/utils.ts",
    "content": "import Joi from './joi'\n\nexport const objectId = Joi.object().keys({\n  id: Joi.objectId().label('Object ID')\n})\n"
  },
  {
    "path": "packages/api/test/global.d.ts",
    "content": "declare namespace NodeJS {\n  interface Global {\n    graphql: Function\n    signIn: Function\n  }\n}\n"
  },
  {
    "path": "packages/api/test/setup.ts",
    "content": "import request, { Test } from 'supertest'\nimport { MongoMemoryServer } from 'mongodb-memory-server'\nimport mongoose from 'mongoose'\nimport createApp from '../src/app'\nimport { DB_OPTIONS } from '../src/config'\n\nconst {\n  app,\n  server: { graphqlPath }\n} = createApp()\n\nconst req = request(app)\n\nglobal.graphql = (): Test => req.post(graphqlPath)\n\nglobal.signIn = (email: string, password: string) =>\n  global.graphql().send({\n    query: `\n          mutation {\n            signIn(\n              email: \"${email}\",\n              password: \"${password}\"\n            ) {\n              id\n            }\n          }\n        `\n  })\n\nlet mongod: MongoMemoryServer\n\nbeforeAll(async () => {\n  mongod = new MongoMemoryServer()\n\n  const uri = await mongod.getConnectionString()\n\n  await mongoose.connect(uri, DB_OPTIONS)\n})\n\nafterAll(async () => {\n  await mongoose.disconnect()\n\n  await mongod.stop()\n})\n"
  },
  {
    "path": "packages/api/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Basic Options */\n    \"target\": \"esnext\",                       /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */\n    \"module\": \"commonjs\",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */\n    // \"lib\": [],                             /* Specify library files to be included in the compilation. */\n    // \"allowJs\": true,                       /* Allow javascript files to be compiled. */\n    // \"checkJs\": true,                       /* Report errors in .js files. */\n    // \"jsx\": \"preserve\",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */\n    // \"declaration\": true,                   /* Generates corresponding '.d.ts' file. */\n    // \"declarationMap\": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */\n    \"sourceMap\": true,                        /* Generates corresponding '.map' file. */\n    // \"outFile\": \"./\",                       /* Concatenate and emit output to single file. */\n    \"outDir\": \"./dist\",                       /* Redirect output structure to the directory. */\n    \"rootDir\": \"./\",                          /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */\n    // \"composite\": true,                     /* Enable project compilation */\n    // \"incremental\": true,                   /* Enable incremental compilation */\n    // \"tsBuildInfoFile\": \"./\",               /* Specify file to store incremental compilation information */\n    \"removeComments\": true,                   /* Do not emit comments to output. */\n    // \"noEmit\": true,                        /* Do not emit outputs. */\n    // \"importHelpers\": true,                 /* Import emit helpers from 'tslib'. */\n    // \"downlevelIteration\": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */\n    // \"isolatedModules\": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */\n\n    /* Strict Type-Checking Options */\n    \"strict\": true,                           /* Enable all strict type-checking options. */\n    // \"noImplicitAny\": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */\n    // \"strictNullChecks\": true,              /* Enable strict null checks. */\n    // \"strictFunctionTypes\": true,           /* Enable strict checking of function types. */\n    // \"strictBindCallApply\": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */\n    // \"strictPropertyInitialization\": true,  /* Enable strict checking of property initialization in classes. */\n    // \"noImplicitThis\": true,                /* Raise error on 'this' expressions with an implied 'any' type. */\n    // \"alwaysStrict\": true,                  /* Parse in strict mode and emit \"use strict\" for each source file. */\n\n    /* Additional Checks */\n    // \"noUnusedLocals\": true,                /* Report errors on unused locals. */\n    // \"noUnusedParameters\": true,            /* Report errors on unused parameters. */\n    // \"noImplicitReturns\": true,             /* Report error when not all code paths in function return a value. */\n    // \"noFallthroughCasesInSwitch\": true,    /* Report errors for fallthrough cases in switch statement. */\n\n    /* Module Resolution Options */\n    // \"moduleResolution\": \"node\",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */\n    // \"baseUrl\": \"./\",                       /* Base directory to resolve non-absolute module names. */\n    // \"paths\": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */\n    // \"rootDirs\": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */\n    // \"typeRoots\": [],                       /* List of folders to include type definitions from. */\n    // \"types\": [],                           /* Type declaration files to be included in compilation. */\n    // \"allowSyntheticDefaultImports\": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */\n    \"esModuleInterop\": true                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */\n    // \"preserveSymlinks\": true,              /* Do not resolve the real path of symlinks. */\n\n    /* Source Map Options */\n    // \"sourceRoot\": \"\",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */\n    // \"mapRoot\": \"\",                         /* Specify the location where debugger should locate map files instead of generated locations. */\n    // \"inlineSourceMap\": true,               /* Emit a single file with source maps instead of having a separate file. */\n    // \"inlineSources\": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */\n\n    /* Experimental Options */\n    // \"experimentalDecorators\": true,        /* Enables experimental support for ES7 decorators. */\n    // \"emitDecoratorMetadata\": true,         /* Enables experimental support for emitting type metadata for decorators. */\n  }\n}\n"
  },
  {
    "path": "packages/api/tsconfig.prod.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"./src\"\n  },\n  // test is included in tsconfig.json to resolve @types/jest in the IDE\n  \"exclude\": [\"src/**/__tests__\", \"src/**/__fixtures__\", \"test\"]\n}\n"
  },
  {
    "path": "packages/web/.dockerignore",
    "content": "dist\n\nnode_modules\n\n.dockerignore\n\nDockerfile\n\n*.log\n\nreadme.md\n"
  },
  {
    "path": "packages/web/Dockerfile",
    "content": "FROM node:12-alpine AS builder\n\nWORKDIR /usr/src/app\n\nCOPY package*.json ./\n\nRUN npm ci\n\nCOPY . .\n\nRUN npm run build\n\nFROM nginx:alpine\n\nCOPY --from=builder /usr/src/app/dist/ /usr/share/nginx/html\n\nCOPY proxy.conf /etc/nginx/conf.d/default.conf\n"
  },
  {
    "path": "packages/web/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta\n      name=\"viewport\"\n      content=\"minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no\"\n    />\n    <link\n      rel=\"stylesheet\"\n      href=\"https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap\"\n    />\n    <title>GraphQL Chat</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/web/package.json",
    "content": "{\n  \"name\": \"@graphql-chat/web\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"dist/main.js\",\n  \"description\": \"Real-time GraphQL chat UI\",\n  \"author\": \"Alex\",\n  \"license\": \"MIT\",\n  \"scripts\": {\n    \"dev\": \"webpack-dev-server --mode development\",\n    \"prebuild\": \"rimraf dist\",\n    \"build\": \"webpack --mode production --progress\"\n  },\n  \"homepage\": \"https://github.com/alex996/graphql-chat#readme\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/alex996/graphql-chat.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/alex996/graphql-chat/issues\"\n  },\n  \"dependencies\": {\n    \"@apollo/react-hooks\": \"^3.1.3\",\n    \"@material-ui/core\": \"^4.7.0\",\n    \"@material-ui/icons\": \"^4.5.1\",\n    \"apollo-cache-inmemory\": \"^1.6.3\",\n    \"apollo-client\": \"^2.6.4\",\n    \"apollo-link\": \"^1.2.13\",\n    \"apollo-link-error\": \"^1.1.12\",\n    \"apollo-link-http\": \"^1.5.16\",\n    \"graphql\": \"^14.5.8\",\n    \"graphql-tag\": \"^2.10.1\",\n    \"react\": \"^16.12.0\",\n    \"react-dom\": \"^16.12.0\",\n    \"react-router-dom\": \"^5.1.2\"\n  },\n  \"devDependencies\": {\n    \"@types/graphql\": \"^14.5.0\",\n    \"@types/react\": \"^16.9.13\",\n    \"@types/react-dom\": \"^16.9.4\",\n    \"@types/react-router-dom\": \"^5.1.3\",\n    \"html-webpack-plugin\": \"^4.0.0-beta.11\",\n    \"rimraf\": \"^3.0.0\",\n    \"script-ext-html-webpack-plugin\": \"^2.1.4\",\n    \"ts-loader\": \"^6.2.1\",\n    \"typescript\": \"^3.7.2\",\n    \"webpack\": \"^4.41.2\",\n    \"webpack-cli\": \"^3.3.10\",\n    \"webpack-dev-server\": \"^3.9.0\"\n  }\n}\n"
  },
  {
    "path": "packages/web/proxy.conf",
    "content": "server {\n  listen 80;\n\n  location / {\n    root /usr/share/nginx/html;\n\n    try_files $uri $uri/ /index.html =404;\n  }\n}\n"
  },
  {
    "path": "packages/web/readme.md",
    "content": "# @graphql-chat/web\n\nReal-time GraphQL chat web UI.\n"
  },
  {
    "path": "packages/web/src/App.tsx",
    "content": "import React from 'react'\nimport { CssBaseline } from '@material-ui/core'\nimport { styled, Theme } from '@material-ui/core/styles'\nimport { ApolloProvider } from '@apollo/react-hooks'\nimport { BrowserRouter, Switch, Route } from 'react-router-dom'\nimport { NavBar, Welcome, Home, Login, Register, NotFound } from './views'\nimport { PublicRoute, PrivateRoute } from './components'\nimport client from './apollo'\n\nconst Main = styled('main')(({ theme }: { theme: Theme }) => ({\n  display: 'flex',\n  [theme.breakpoints.up('sm')]: {\n    marginTop: 64\n  },\n  [theme.breakpoints.only('xs')]: {\n    marginTop: 56\n  }\n}))\n\nconst App = () => (\n  <ApolloProvider client={client}>\n    <CssBaseline />\n    <BrowserRouter>\n      <NavBar />\n      <Main>\n        <Switch>\n          <Route exact path='/' component={Welcome} />\n          <PublicRoute path='/login' component={Login} />\n          <PublicRoute path='/register' component={Register} />\n          <PrivateRoute path='/home' component={Home} />\n          <Route component={NotFound} />\n        </Switch>\n      </Main>\n    </BrowserRouter>\n  </ApolloProvider>\n)\n\nexport default App\n"
  },
  {
    "path": "packages/web/src/apollo.ts",
    "content": "import { ApolloClient } from 'apollo-client'\nimport { InMemoryCache } from 'apollo-cache-inmemory'\nimport { HttpLink } from 'apollo-link-http'\nimport { onError } from 'apollo-link-error'\nimport { ApolloLink } from 'apollo-link'\n\nconst client = new ApolloClient({\n  link: ApolloLink.from([\n    onError(({ graphQLErrors, networkError }) => {\n      if (graphQLErrors) {\n        graphQLErrors.map(({ message, locations, path }) =>\n          console.log(\n            `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`\n          )\n        )\n      }\n      if (networkError) console.log(`[Network error]: ${networkError}`)\n    }),\n    new HttpLink({\n      uri: process.env.API_URI,\n      credentials: 'same-origin'\n    })\n  ]),\n  cache: new InMemoryCache()\n})\n\nexport default client\n"
  },
  {
    "path": "packages/web/src/auth.ts",
    "content": "const IS_LOGGED_IN = 'isLoggedIn'\n\nexport const rememberLogin = () => localStorage.setItem(IS_LOGGED_IN, '')\n\nexport const forgetLogin = () => localStorage.removeItem(IS_LOGGED_IN)\n\nexport const isLoggedIn = () => IS_LOGGED_IN in localStorage\n"
  },
  {
    "path": "packages/web/src/components/AdapterLink.tsx",
    "content": "import React, { forwardRef, RefForwardingComponent } from 'react'\nimport { Link, LinkProps } from 'react-router-dom'\n\nconst AdapterLink: RefForwardingComponent<HTMLAnchorElement, LinkProps> = (\n  props,\n  ref\n) => <Link innerRef={ref} {...props} />\n\nexport default forwardRef(AdapterLink)\n"
  },
  {
    "path": "packages/web/src/components/CallToAction.tsx",
    "content": "import React from 'react'\nimport { makeStyles, Theme, Button } from '@material-ui/core'\nimport { ButtonProps } from '@material-ui/core/Button'\nimport { AdapterLink } from './'\n\n// Required to use hook API until material-ui@15695 is fixed\nconst useStyles = makeStyles((theme: Theme) => ({\n  root: {\n    width: '100%',\n    borderRadius: theme.spacing(4),\n    padding: theme.spacing(1.5)\n  }\n}))\n\ninterface Props extends ButtonProps {\n  to?: string\n}\n\n// A full-width fab, optionally outlined\nconst CallToAction = (props: Props) => {\n  const classes = useStyles()\n  return (\n    <Button\n      {...(props.to && { component: AdapterLink })}\n      className={classes.root}\n      variant='contained'\n      color='primary'\n      size='large'\n      {...props}\n    />\n  )\n}\n\nexport default CallToAction\n"
  },
  {
    "path": "packages/web/src/components/ProtectedRoute.tsx",
    "content": "import React, { FC } from 'react'\nimport {\n  RouteProps,\n  Redirect,\n  RouteComponentProps,\n  Route\n} from 'react-router-dom'\nimport { isLoggedIn } from '../auth'\n\ninterface Props extends RouteProps {\n  allowed: boolean\n  redirectTo: string\n}\n\nconst ProtectedRoute: FC<Props> = ({\n  allowed,\n  redirectTo,\n  component: Component,\n  render,\n  children,\n  ...rest\n}) => (\n  <Route\n    {...rest}\n    render={(props: RouteComponentProps) => {\n      if (allowed) {\n        if (Component) {\n          return <Component {...props} />\n        } else if (render) {\n          return render(props)\n        } else {\n          return children\n        }\n      }\n\n      return <Redirect to={redirectTo} />\n    }}\n  />\n)\n\nexport const PrivateRoute: FC<RouteProps> = props => (\n  <ProtectedRoute {...props} allowed={isLoggedIn()} redirectTo='/login' />\n)\n\nexport const PublicRoute: FC<RouteProps> = props => (\n  <ProtectedRoute {...props} allowed={!isLoggedIn()} redirectTo='/home' />\n)\n"
  },
  {
    "path": "packages/web/src/components/index.ts",
    "content": "export { default as AdapterLink } from './AdapterLink'\n\nexport { default as CallToAction } from './CallToAction'\n\nexport * from './ProtectedRoute'\n"
  },
  {
    "path": "packages/web/src/hooks/index.ts",
    "content": "export { default as useInput } from './useInput'\n"
  },
  {
    "path": "packages/web/src/hooks/useInput.ts",
    "content": "import { useState, useCallback, ChangeEvent } from 'react'\n\nconst useInput = (initialValue = '') => {\n  const [value, setValue] = useState(initialValue)\n\n  const onChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => setValue(e.currentTarget.value),\n    []\n  )\n\n  return {\n    value,\n    onChange\n  }\n}\n\nexport default useInput\n"
  },
  {
    "path": "packages/web/src/icons/index.ts",
    "content": "export { default as AccountCircle } from '@material-ui/icons/AccountCircle'\n\nexport { default as Menu } from '@material-ui/icons/Menu'\n\nexport { default as MoreVert } from '@material-ui/icons/MoreVert'\n\nexport { default as Person } from '@material-ui/icons/Person'\n"
  },
  {
    "path": "packages/web/src/index.tsx",
    "content": "import React from 'react'\nimport { render } from 'react-dom'\nimport App from './App'\n\nrender(<App />, document.getElementById('app'))\n"
  },
  {
    "path": "packages/web/src/views/Home.tsx",
    "content": "import React from 'react'\nimport { Box, Typography } from '@material-ui/core'\n\nconst Home = () => (\n  <Box marginTop={2}>\n    <Typography variant='h6'>Home</Typography>\n  </Box>\n)\n\nexport default Home\n"
  },
  {
    "path": "packages/web/src/views/Welcome.tsx",
    "content": "import React from 'react'\nimport { Box, Grid, Typography } from '@material-ui/core'\nimport { CallToAction } from '../components'\nimport { isLoggedIn } from '../auth'\n\nconst Welcome = () => (\n  <Box marginTop={10} marginBottom={10} padding={3} flex={1}>\n    <Grid container spacing={6} justify='center'>\n      <Grid item>\n        <Typography variant='h3' align='center' gutterBottom>\n          Welcome\n        </Typography>\n        <Typography variant='h6' align='center'>\n          Real-time chat app built with MERN stack and GraphQL\n        </Typography>\n      </Grid>\n      <Grid item xs={12} sm={6} lg={4} xl={3}>\n        <Grid container spacing={2} direction='row-reverse' justify='center'>\n          {isLoggedIn() ? (\n            <Grid item xs={12} md={6}>\n              <CallToAction to='/home'>Home</CallToAction>\n            </Grid>\n          ) : (\n            <>\n              <Grid item xs={12} md={6}>\n                <CallToAction to='/login'>Log In</CallToAction>\n              </Grid>\n              <Grid item xs={12} md={6}>\n                <CallToAction to='/register' variant='outlined'>\n                  Register\n                </CallToAction>\n              </Grid>\n            </>\n          )}\n        </Grid>\n      </Grid>\n    </Grid>\n  </Box>\n)\n\nexport default Welcome\n"
  },
  {
    "path": "packages/web/src/views/auth/Login.tsx",
    "content": "import React, { FormEvent } from 'react'\nimport gql from 'graphql-tag'\nimport { RouteComponentProps } from 'react-router-dom'\nimport { styled } from '@material-ui/core/styles'\nimport { useMutation } from '@apollo/react-hooks'\nimport { Grid, Avatar as MuiAvatar, TextField, Link } from '@material-ui/core'\nimport { CallToAction, AdapterLink } from '../../components'\nimport { rememberLogin } from '../../auth'\nimport { useInput } from '../../hooks'\nimport { Person } from '../../icons'\nimport { PaperBox } from './'\n\nconst Avatar = styled(MuiAvatar)({\n  width: 120,\n  height: 120\n})\n\nconst Icon = styled(Person)({\n  fontSize: 100\n})\n\nconst LOG_IN = gql`\n  mutation signIn($email: String!, $password: String!) {\n    signIn(email: $email, password: $password) {\n      id\n    }\n  }\n`\n\nconst Login = (props: RouteComponentProps) => {\n  const email = useInput()\n  const password = useInput()\n\n  const [logIn, { loading }] = useMutation(LOG_IN, {\n    variables: {\n      email: email.value,\n      password: password.value\n    }\n  })\n\n  const handleSubmit = async (e: FormEvent) => {\n    e.preventDefault()\n\n    // TODO: err handling\n\n    await logIn()\n\n    // TODO: this needs to be invalidated after 2h\n    rememberLogin()\n\n    props.history.push('/home')\n  }\n\n  return (\n    <PaperBox>\n      <form onSubmit={handleSubmit}>\n        <Grid container spacing={2} justify='center'>\n          <Grid item>\n            <Avatar>\n              <Icon />\n            </Avatar>\n          </Grid>\n          <Grid item xs={12}>\n            <TextField\n              {...email}\n              type='email'\n              label='Email'\n              variant='outlined'\n              placeholder='john.smith@example.com'\n              fullWidth\n              required\n            />\n            <TextField\n              {...password}\n              type='password'\n              label='Password'\n              variant='outlined'\n              placeholder={'*'.repeat(12)}\n              margin='normal'\n              fullWidth\n              required\n            />\n          </Grid>\n          <Grid item xs={12}>\n            <CallToAction type='submit' disabled={loading}>\n              Login\n            </CallToAction>\n          </Grid>\n          <Grid item xs={12}>\n            <Grid container justify='space-between'>\n              <Grid item>\n                <Link component={AdapterLink} to='/reset'>\n                  Forgot password?\n                </Link>\n              </Grid>\n              <Grid item>\n                <Link component={AdapterLink} to='/register' variant='body2'>\n                  {\"Don't have an account?\"}\n                </Link>\n              </Grid>\n            </Grid>\n          </Grid>\n        </Grid>\n      </form>\n    </PaperBox>\n  )\n}\nexport default Login\n"
  },
  {
    "path": "packages/web/src/views/auth/PaperBox.tsx",
    "content": "import React from 'react'\nimport { styled, Theme } from '@material-ui/core/styles'\nimport { Box, Grid, Paper as MuiPaper } from '@material-ui/core'\nimport { PaperProps } from '@material-ui/core/Paper'\n\nconst Paper = styled(MuiPaper)(({ theme }: { theme: Theme }) => ({\n  padding: theme.spacing(2),\n  maxWidth: 400\n}))\n\nconst PaperBox = (props: PaperProps) => (\n  <Box marginTop={10} marginBottom={10} padding={2} flex={1}>\n    <Grid container justify='center' spacing={4}>\n      <Grid item>\n        <Paper elevation={2} {...props} />\n      </Grid>\n    </Grid>\n  </Box>\n)\n\nexport default PaperBox\n"
  },
  {
    "path": "packages/web/src/views/auth/Register.tsx",
    "content": "import React, { FormEvent } from 'react'\nimport gql from 'graphql-tag'\nimport { RouteComponentProps } from 'react-router-dom'\nimport { useMutation } from '@apollo/react-hooks'\nimport { Grid, TextField, Link } from '@material-ui/core'\nimport { CallToAction, AdapterLink } from '../../components'\nimport { rememberLogin } from '../../auth'\nimport { useInput } from '../../hooks'\nimport { PaperBox } from './'\n\nconst REGISTER = gql`\n  mutation signUp(\n    $email: String!\n    $username: String!\n    $name: String!\n    $password: String!\n  ) {\n    signUp(\n      email: $email\n      username: $username\n      name: $name\n      password: $password\n    ) {\n      id\n    }\n  }\n`\n\nconst Register = (props: RouteComponentProps) => {\n  const name = useInput()\n  const username = useInput()\n  const email = useInput()\n  const password = useInput()\n  const passwordConfirmation = useInput()\n\n  const [register, { loading }] = useMutation(REGISTER, {\n    variables: {\n      name: name.value,\n      username: username.value,\n      email: email.value,\n      password: password.value\n    }\n  })\n\n  const handleSubmit = async (e: FormEvent) => {\n    e.preventDefault()\n\n    // TODO: err handling\n\n    await register()\n\n    // TODO: this needs to be invalidated after 2h\n    rememberLogin()\n\n    props.history.push('/home')\n  }\n\n  return (\n    <PaperBox>\n      <form onSubmit={handleSubmit}>\n        <Grid container spacing={2} justify='center'>\n          <Grid item xs={12}>\n            <TextField\n              {...name}\n              label='Name'\n              variant='outlined'\n              placeholder='John Smith'\n              margin='normal'\n              fullWidth\n              required\n            />\n            <TextField\n              {...username}\n              label='Username'\n              variant='outlined'\n              placeholder='johnsmith12'\n              margin='normal'\n              fullWidth\n              required\n            />\n            <TextField\n              {...email}\n              type='email'\n              label='Email'\n              variant='outlined'\n              placeholder='john.smith@example.com'\n              margin='normal'\n              fullWidth\n              required\n            />\n            <TextField\n              {...password}\n              type='password'\n              label='Password'\n              variant='outlined'\n              placeholder={'*'.repeat(12)}\n              margin='normal'\n              fullWidth\n              required\n            />\n            <TextField\n              {...passwordConfirmation}\n              type='password'\n              label='Confirm Password'\n              variant='outlined'\n              placeholder={'*'.repeat(12)}\n              margin='normal'\n              fullWidth\n              required\n            />\n          </Grid>\n          <Grid item xs={12}>\n            <CallToAction type='submit' disabled={loading}>\n              Register\n            </CallToAction>\n          </Grid>\n          <Grid item>\n            <Link component={AdapterLink} to='/login' variant='body2'>\n              Already have an account?\n            </Link>\n          </Grid>\n        </Grid>\n      </form>\n    </PaperBox>\n  )\n}\n\nexport default Register\n"
  },
  {
    "path": "packages/web/src/views/auth/index.ts",
    "content": "export { default as Login } from './Login'\n\nexport { default as PaperBox } from './PaperBox'\n\nexport { default as Register } from './Register'\n"
  },
  {
    "path": "packages/web/src/views/errors/404.tsx",
    "content": "import React from 'react'\n\nconst NotFound = () => <h1>404 Not Found</h1>\n\nexport default NotFound\n"
  },
  {
    "path": "packages/web/src/views/errors/index.ts",
    "content": "export { default as NotFound } from './404'\n"
  },
  {
    "path": "packages/web/src/views/index.ts",
    "content": "export * from './auth'\n\nexport * from './errors'\n\nexport * from './layouts'\n\nexport { default as Home } from './Home'\n\nexport { default as Welcome } from './Welcome'\n"
  },
  {
    "path": "packages/web/src/views/layouts/NavBar.tsx",
    "content": "import React from 'react'\nimport { styled } from '@material-ui/styles'\nimport { Link, AppBar, Toolbar, IconButton } from '@material-ui/core'\nimport { AdapterLink } from '../../components'\nimport { MoreVert } from '../../icons'\nimport { SideBar } from './'\n\nconst FullWidth = styled('div')({\n  flexGrow: 1\n})\n\nconst Navbar = () => (\n  <AppBar position='fixed'>\n    <Toolbar>\n      <SideBar />\n      <FullWidth>\n        <Link\n          to='/'\n          component={AdapterLink}\n          color='inherit'\n          underline='none'\n          variant='h6'\n        >\n          GraphQL Chat\n        </Link>\n      </FullWidth>\n      <IconButton edge='end' color='inherit'>\n        <MoreVert />\n      </IconButton>\n    </Toolbar>\n  </AppBar>\n)\n\nexport default Navbar\n"
  },
  {
    "path": "packages/web/src/views/layouts/SideBar.tsx",
    "content": "import React, { useState } from 'react'\nimport gql from 'graphql-tag'\nimport {\n  Drawer,\n  IconButton,\n  List,\n  ListItem,\n  ListItemText,\n  makeStyles\n} from '@material-ui/core'\nimport { RouteComponentProps, withRouter } from 'react-router-dom'\nimport { useMutation } from '@apollo/react-hooks'\nimport { AdapterLink } from '../../components'\nimport { isLoggedIn, forgetLogin } from '../../auth'\nimport { Menu } from '../../icons'\n\nconst useStyles = makeStyles({\n  list: {\n    width: 250\n  }\n})\n\nconst LOG_OUT = gql`\n  mutation {\n    signOut\n  }\n`\n\ninterface LinkListProps {\n  links: {\n    to?: string\n    text: string\n    onClick: VoidFunction\n    disabled?: boolean\n  }[]\n}\n\nconst LinkList = ({ links }: LinkListProps) => {\n  const classes = useStyles()\n\n  return (\n    <List component='nav' className={classes.list}>\n      {links.map(({ to, text, ...rest }, index) => (\n        <ListItem\n          button\n          key={index}\n          {...(to && {\n            to,\n            component: AdapterLink\n          })}\n          {...rest}\n        >\n          <ListItemText primary={text} />\n        </ListItem>\n      ))}\n    </List>\n  )\n}\n\ninterface GuestListProps {\n  onToggle: VoidFunction\n}\n\nconst GuestList = ({ onToggle }: GuestListProps) => {\n  const links = [\n    {\n      to: '/login',\n      text: 'Log In',\n      onClick: onToggle\n    },\n    {\n      to: '/register',\n      text: 'Register',\n      onClick: onToggle\n    }\n  ]\n\n  return <LinkList links={links} />\n}\n\ninterface AuthListProps extends RouteComponentProps {\n  onToggle: VoidFunction\n}\n\nconst AuthList = withRouter(({ onToggle, history }: AuthListProps) => {\n  const [logOut, { loading }] = useMutation(LOG_OUT)\n\n  const handleLogout = async () => {\n    await logOut()\n\n    forgetLogin()\n\n    onToggle()\n\n    history.push('/login')\n  }\n\n  const links = [\n    {\n      to: '/home',\n      text: 'Home',\n      onClick: onToggle\n    },\n    {\n      to: '/profile',\n      text: 'Profile',\n      onClick: onToggle\n    },\n    {\n      text: 'Log Out',\n      onClick: handleLogout,\n      disabled: loading\n    }\n  ]\n\n  return <LinkList links={links} />\n})\n\nconst SideBar = () => {\n  const [open, setOpen] = useState(false)\n  const handleToggle = () => setOpen(!open)\n\n  return (\n    <>\n      <IconButton edge='start' color='inherit' onClick={handleToggle}>\n        <Menu />\n      </IconButton>\n      <Drawer anchor='left' open={open} onClose={handleToggle}>\n        {isLoggedIn() ? (\n          <AuthList onToggle={handleToggle} />\n        ) : (\n          <GuestList onToggle={handleToggle} />\n        )}\n      </Drawer>\n    </>\n  )\n}\n\nexport default SideBar\n"
  },
  {
    "path": "packages/web/src/views/layouts/index.ts",
    "content": "export { default as NavBar } from './NavBar'\n\nexport { default as SideBar } from './SideBar'\n"
  },
  {
    "path": "packages/web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Basic Options */\n    // \"incremental\": true,                   /* Enable incremental compilation */\n    \"target\": \"esnext\",                       /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */\n    \"module\": \"esnext\",                       /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */\n    // \"lib\": [],                             /* Specify library files to be included in the compilation. */\n    // \"allowJs\": true,                       /* Allow javascript files to be compiled. */\n    // \"checkJs\": true,                       /* Report errors in .js files. */\n    \"jsx\": \"react\",                           /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */\n    // \"declaration\": true,                   /* Generates corresponding '.d.ts' file. */\n    // \"declarationMap\": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */\n    \"sourceMap\": true,                        /* Generates corresponding '.map' file. */\n    // \"outFile\": \"./\",                       /* Concatenate and emit output to single file. */\n    \"outDir\": \"./dist\",                       /* Redirect output structure to the directory. */\n    // \"rootDir\": \"./\",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */\n    // \"composite\": true,                     /* Enable project compilation */\n    // \"tsBuildInfoFile\": \"./\",               /* Specify file to store incremental compilation information */\n    // \"removeComments\": true,                /* Do not emit comments to output. */\n    // \"noEmit\": true,                        /* Do not emit outputs. */\n    // \"importHelpers\": true,                 /* Import emit helpers from 'tslib'. */\n    // \"downlevelIteration\": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */\n    // \"isolatedModules\": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */\n\n    /* Strict Type-Checking Options */\n    \"strict\": true,                           /* Enable all strict type-checking options. */\n    // \"noImplicitAny\": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */\n    // \"strictNullChecks\": true,              /* Enable strict null checks. */\n    // \"strictFunctionTypes\": true,           /* Enable strict checking of function types. */\n    // \"strictBindCallApply\": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */\n    // \"strictPropertyInitialization\": true,  /* Enable strict checking of property initialization in classes. */\n    // \"noImplicitThis\": true,                /* Raise error on 'this' expressions with an implied 'any' type. */\n    // \"alwaysStrict\": true,                  /* Parse in strict mode and emit \"use strict\" for each source file. */\n\n    /* Additional Checks */\n    // \"noUnusedLocals\": true,                /* Report errors on unused locals. */\n    // \"noUnusedParameters\": true,            /* Report errors on unused parameters. */\n    // \"noImplicitReturns\": true,             /* Report error when not all code paths in function return a value. */\n    // \"noFallthroughCasesInSwitch\": true,    /* Report errors for fallthrough cases in switch statement. */\n\n    /* Module Resolution Options */\n    \"moduleResolution\": \"node\",               /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */\n    // \"baseUrl\": \"./\",                       /* Base directory to resolve non-absolute module names. */\n    // \"paths\": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */\n    // \"rootDirs\": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */\n    // \"typeRoots\": [],                       /* List of folders to include type definitions from. */\n    // \"types\": [],                           /* Type declaration files to be included in compilation. */\n    // \"allowSyntheticDefaultImports\": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */\n    \"esModuleInterop\": true                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */\n    // \"preserveSymlinks\": true,              /* Do not resolve the real path of symlinks. */\n    // \"allowUmdGlobalAccess\": true,          /* Allow accessing UMD globals from modules. */\n\n    /* Source Map Options */\n    // \"sourceRoot\": \"\",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */\n    // \"mapRoot\": \"\",                         /* Specify the location where debugger should locate map files instead of generated locations. */\n    // \"inlineSourceMap\": true,               /* Emit a single file with source maps instead of having a separate file. */\n    // \"inlineSources\": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */\n\n    /* Experimental Options */\n    // \"experimentalDecorators\": true,        /* Enables experimental support for ES7 decorators. */\n    // \"emitDecoratorMetadata\": true,         /* Enables experimental support for emitting type metadata for decorators. */\n  }\n}\n"
  },
  {
    "path": "packages/web/webpack.config.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\nconst webpack = require('webpack')\nconst HtmlWebpackPlugin = require('html-webpack-plugin')\nconst ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin')\n\nmodule.exports = (env, { mode }) => {\n  const inDev = mode === 'development'\n\n  return {\n    devtool: inDev ? 'cheap-module-eval-source-map' : 'source-map',\n    output: {\n      filename: inDev ? '[name].js' : '[name].[contenthash].js'\n    },\n    resolve: {\n      extensions: ['.ts', '.tsx', '.js'],\n      alias: {\n        '@material-ui/core': '@material-ui/core/es'\n      }\n    },\n    module: {\n      rules: [{ test: /\\.tsx?$/, loader: 'ts-loader', exclude: /node_modules/ }]\n    },\n    plugins: [\n      new HtmlWebpackPlugin({\n        template: 'index.html'\n      }),\n      new ScriptExtHtmlWebpackPlugin({\n        defaultAttribute: 'defer'\n      }),\n      new webpack.DefinePlugin({\n        'process.env.API_URI': `\"${process.env.API_URI || '/graphql'}\"`\n      })\n    ],\n    devServer: {\n      open: true,\n      port: 4000,\n      compress: true,\n      historyApiFallback: true,\n      proxy: {\n        '/graphql': 'http://localhost:3000'\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "proxy.conf",
    "content": "server {\n  listen 80;\n\n  location /graphql {\n    proxy_pass http://api:3000;\n  }\n\n  location / {\n    proxy_pass http://web;\n  }\n}\n"
  },
  {
    "path": "queries.gql",
    "content": "mutation {\n  signUp(\n    email: \"alex@gmail.com\"\n    username: \"alex\"\n    name: \"Alex\"\n    password: \"Secret12\"\n  ) {\n    id\n  }\n}\n\nmutation {\n  signIn(email: \"alex@gmail.com\", password: \"Secret12\") {\n    id\n  }\n}\n\nmutation {\n  signOut\n}\n\n{\n  me {\n    id\n    name\n    email\n  }\n}\n\n{\n  users {\n    id\n  }\n}\n\n{\n  user(id: \"5ce700eb4b317141fc402006\") {\n    id\n  }\n}\n\nmutation {\n  startChat(userIds: [\"5ce701a4f88411442f4388b6\"]) {\n    id\n    title\n  }\n}\n\n{\n  users {\n    id\n    name\n    email\n    chats {\n      id\n      users {\n        id\n        name\n        email\n      }\n      messages {\n        id\n      }\n      lastMessage {\n        id\n      }\n    }\n  }\n}\n\nmutation {\n  sendMessage(chatId: \"5cf9cd1c99dfe743edcc3158\", body: \"Hey man\") {\n    id\n  }\n}\n"
  },
  {
    "path": "readme.md",
    "content": "# graphql-chat\n\n> Note, this project is under construction :construction: I will resume the [YouTube series](https://www.youtube.com/watch?v=HKqbBrl_fKc&list=PLcCp4mjO-z9_y8lByvIfNgA_F18l-soQv) once it's ready. Until then, you can check the code for the playlist in the [stable](https://github.com/alex996/graphql-chat/tree/stable) branch.\n\nGraphQL chat API & UI monorepo.\n\n## Setup\n\n### Dev\n\n```sh\n# (Linux) Export UID to fix permissions\nexport UID\n\n# Boot the stack; this will\n# - provision mongo & redis\n# - launch api & web\nnpm run up\n\n# Only run api & web\nnpm run dev\n\n# Stop containers\nnpm run stop\n\n# Tear down containers\nnpm run down\n```\n\n### Prod\n\n```sh\n# (Linux) Export UID to fix permissions\nexport UID\n# (Linux) Create volume dir with current user\nmkdir data\n\n# Create env file\ncp .env.example .env\n# or export into shell\nexport $(cat /path/to/.env)\n\n# Boot the stack\ndocker-compose up -d\n\n# View logs\ndocker-compose logs\n\n# Re-build api & web after changes\ndocker-compose build api web\n```\n\n## MVP\n\nAs a user, I can\n\n- sign up / sign in / sign out / reset pwd\n- start a private chat with user(s)\n- invite users to a chat / leave a chat\n- send messages to other user(s)\n- see incoming messages live\n- upload files (images, video, text)\n- maintain privacy (can't read others chats/msgs)\n\n## Next Phase\n\nAs a user, I can\n\n- receive confirmation emails\n- edit my profile\n- start a public group chat\n- see typing indicator\n- customize the theme\n- upload code snippets\n\n## Stack\n\n### BE\n\n- Node + Express + TS\n- GraphQL + Apollo Server + WS\n- express-session + Redis\n- MongoDB + Mongoose\n\n### FE\n\n- React 16.8+\n- Apollo Client\n- Material-UI / Bulma\n\n### DevOps\n\n- nginx\n- Docker + docker-compose\n"
  },
  {
    "path": "reference.md",
    "content": "# Reference\n\n## Lerna\n\n```sh\n# Init. an indep. versioned monorepo\nnpx lerna init --independent\n\n# Create a scoped package\nnpx lerna create @graphql-chat/api --private\n\n# Install a dev dep to a package\nnpx lerna add nodemon --scope=@graphql-chat/api --dev\n\n# Install deps across packages\nnpx lerna bootstrap --hoist\n\n# Run watch script across packages\nnpx lerna run --parallel watch\n```\n\n## MongoDB\n\n```sh\n# Start a container in the background on port 27017 with 'root' user on the 'admin' database\ndocker run -d --name mongodb -p 27017:27017 \\\n  -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD=secret mongo\n\n# Run the mongo CLI client as 'root' against 'admin' database and connect to 'chat'\ndocker exec -it mongodb mongo -u root -p secret --authenticationDatabase admin chat\n\n# Inside the client, create an admin user for the 'chat' database\ndb.createUser({\n  user: 'admin', pwd: 'secret', roles: ['readWrite', 'dbAdmin']\n})\n\n# Verify that you can connect to mongo through the exposed port on your host machine\ncurl 127.0.0.1:27017\n# It looks like you are trying to access MongoDB over HTTP on the native driver port.\n\n# Connect as admin\ndocker exec -it mongodb mongo -u admin -p secret chat\n```\n\n## Redis\n\n```sh\ndocker run -d --name redisdb -p 6379:6379 redis redis-server --requirepass secret\n\ndocker exec -it redisdb redis-cli -a secret\n```\n\n## Docker\n\n```sh\n# Build a container, tag with a name\ndocker build -t chat-api .\n\n# Run a container in detached mode\ndocker run -d -p 3000:3000 chat-api\n\n# SSH into the container\ndocker exec -it chat-api sh\n\n# Remove dangling images\ndocker rmi $(docker images --quiet --filter \"dangling=true\")\n\n# Remove stopped containers\ndocker rm $(docker ps -a -q)\n```\n\n## docker-compose\n\n- [CLI ref](https://docs.docker.com/compose/reference/overview/)\n- [environment vars](https://docs.docker.com/compose/environment-variables/)\n- basic [node.js guide](https://nodejs.org/en/docs/guides/nodejs-docker-webapp/)\n- specific services `docker-compose up chat-db chat-cache`\n- start & rebuild `docker-compose up --build`\n\n## Docker best practices\n\n> See [this](https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md) and [this](https://docs.docker.com/v17.09/engine/userguide/eng-image/dockerfile_best-practices/)\n\n- Run node with `USER node` instead of `root`\n- Use `FROM node:alpine` base image\n- Don't map `node_modules` volume to your container\n  - local `node_modules` may contain OS-specific (Mac, Windows) binaries\n- Make sure to pass environment vars, not shell vars\n  - use `export` and not simply `source .env` or `. .env` (see [this](https://forums.docker.com/t/docker-compose-not-seeing-environment-variables-on-the-host/11837/3))\n    - otherwise, make sure to use `set -a` (see [this](https://stackoverflow.com/a/33186458))\n  - `echo $DB_USERNAME` vs. `printenv | grep DB_USERNAME` (see [this](https://github.com/docker/compose/issues/4189#issuecomment-320362242))\n- Make env vars configurable\n  - e.g. [mongo](https://github.com/docker-library/mongo/issues/257#issuecomment-375747688) or [redis](https://github.com/docker-library/redis/issues/46#issuecomment-363117342)\n\n## Nginx\n\n- [basic setup](https://gist.github.com/soheilhy/8b94347ff8336d971ad0)\n\n## Testing\n\n1. `jest` + `ts-jest` & [`@shelf/jest-mongodb`](https://jestjs.io/docs/en/mongodb) presets\n\n```js\n// jest.config.js\nmodule.exports = merge.recursive(ts, mongo, { ... })\n```\n\n- parallel, but doesn't expose options for a one-time global setup\n  - `globalSetup`/`globalTeardown` run in separate processes (can't share `global` vars)\n  - `setupFiles`/`setupFilesAfterEnv` run for **each** test file (one mongod process per file (!))\n- requires `mongodb-memory-server` with a mongod binary (70+ MB) which needs to be cached in CI\n- could run a `pretest` script, but `posttest` is not guaranteed to be reached\n- could make it work with a [custom `testEnvironment`](https://github.com/facebook/jest/issues/3832#issuecomment-375544901) (see [this](https://itnext.io/parallel-testing-a-graphql-server-with-jest-44e206f3e7d2))\n\n2. `mocha` + `ts-node` + `mongodb-memory-server`\n\n```sh\nmocha -r ts-node/register src/**/__tests__/*.ts\n```\n\n- sequential, but allows for a one-time [global setup/teardown](https://github.com/mochajs/mocha/issues/1460#issuecomment-93862610)\n- cannot require [`.d.ts` files](https://github.com/TypeStrong/ts-node/issues/797), so can't declare global funcs\n- poor linting, `\"plugin:mocha/recommended\"` doesn't work, use `\"env\": [\"mocha\": true]`\n\n3. `apollo-server-testing`\n\n- doesn't respect `express` middleware (and thus `express-session`, thus no auth\n"
  }
]