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