├── app ├── src │ ├── components │ │ ├── QuestionList.js │ │ ├── Home.js │ │ ├── SessionItem.js │ │ ├── App.js │ │ ├── SessionList.js │ │ ├── LinkList.js │ │ ├── Header.js │ │ ├── CreateLink.js │ │ ├── Link.js │ │ ├── CreateSession.js │ │ ├── CreateQuestion.js │ │ ├── Session.js │ │ ├── QuestionItem.js │ │ └── Login.js │ ├── constants.js │ ├── setupTests.js │ ├── App.test.js │ ├── styles │ │ ├── App.css │ │ └── index.css │ ├── index.js │ ├── logo.svg │ └── serviceWorker.js ├── .env ├── .env.production ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── README.md └── package.json ├── .gitignore ├── README.md └── api ├── src ├── resolver │ ├── Question.js │ ├── User.js │ ├── Vote.js │ ├── Query.js │ ├── Session.js │ └── Mutation.js ├── repository │ ├── dynamodb │ │ ├── DynamoDbClient.js │ │ ├── DynamoDbRepository.js │ │ ├── DynamoDbUserRepository.js │ │ ├── DynamoDbVoteRepository.js │ │ ├── DynamoDbQuestionRepository.js │ │ ├── DynamoDbSessionRepository.js │ │ └── DynamoDbBaseRepository.js │ ├── UserRepository.js │ ├── QuestionRepository.js │ ├── SessionRepository.js │ └── VoteRepository.js ├── data │ └── dynamodb │ │ ├── samples │ │ ├── ListTables.js │ │ ├── docker-compose.yml │ │ ├── DynamoDB.md │ │ ├── DeleteItem.js │ │ ├── AtomicUpdateItem.js │ │ ├── UpdateItem.js │ │ ├── CreateTables.js │ │ ├── ReadItem.js │ │ └── CreateItem.js │ │ └── InitTables.js ├── __test__ │ └── resolver │ │ ├── Question.test.js │ │ ├── User.test.js │ │ ├── Vote.test.js │ │ ├── Query.test.js │ │ ├── Session.test.js │ │ └── Mutation.test.js ├── authentication.js ├── handler.js ├── local.js ├── schema.graphql └── serverless.yml ├── README.md └── package.json /app/src/components/QuestionList.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:4000/dev/api -------------------------------------------------------------------------------- /app/src/constants.js: -------------------------------------------------------------------------------- 1 | export const AUTH_TOKEN = 'auth-token' 2 | export const EMAIL = 'email' 3 | -------------------------------------------------------------------------------- /app/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://sz4llai3tg.execute-api.eu-central-1.amazonaws.com/dev/api -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilhan-mstf/Kuestion/master/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilhan-mstf/Kuestion/master/app/public/logo192.png -------------------------------------------------------------------------------- /app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilhan-mstf/Kuestion/master/app/public/logo512.png -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # Kuestion web app 2 | - React 3 | - Apollo GraphQL Client 4 | 5 | ## To run 6 | `yarn start` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | node_modules 3 | **/dynamodb/instance 4 | **/build 5 | **/layer 6 | **/.serverless 7 | .env*.local -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kuestion 2 | Question collecting and voting app for live Q&A sessions. 3 | 4 | ## Tech 5 | - GraphQL 6 | - DynamoDb 7 | - Nodejs 8 | - React 9 | 10 | ## Css lib 11 | - http://tachyons.io/ -------------------------------------------------------------------------------- /api/src/resolver/Question.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | id: (parent) => parent.id, 3 | createdAt: (parent) => parent.createdAt + '', 4 | text: (parent) => parent.text, 5 | voteCount: (parent) => parent.voteCount 6 | } 7 | -------------------------------------------------------------------------------- /app/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /api/src/resolver/User.js: -------------------------------------------------------------------------------- 1 | const SessionRepository = require('../repository/SessionRepository') 2 | 3 | module.exports = { 4 | name: (parent) => parent.name, 5 | email: (parent) => parent.email, 6 | sessions: (parent, args, context) => SessionRepository.getSessionsOfUser(context.repo, parent.email) 7 | } 8 | -------------------------------------------------------------------------------- /app/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbClient.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | if (process.env.IS_OFFLINE) { 4 | AWS.config.update({ 5 | region: process.env.REGION, 6 | endpoint: process.env.DYNAMODB_ENDPOINT 7 | }) 8 | } 9 | 10 | const docClient = new AWS.DynamoDB.DocumentClient() 11 | 12 | module.exports = { 13 | docClient 14 | } -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Kuestion api 2 | - DynamoDb 3 | - GraphQL 4 | - Nodejs 5 | 6 | ## Init Db 7 | `node src/data/dynamodb/InitTables.js` 8 | 9 | ## Serverless 10 | 11 | ## Test 12 | curl \ 13 | -X POST \ 14 | -H "Content-Type: application/json" \ 15 | --data '{ "query": "{ info }" }' \ 16 | https://18nkkf0lz0.execute-api.eu-central-1.amazonaws.com/dev/api -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbRepository.js: -------------------------------------------------------------------------------- 1 | const User = require('./DynamoDbUserRepository') 2 | const Session = require('./DynamoDbSessionRepository') 3 | const Question = require('./DynamoDbQuestionRepository') 4 | const Vote = require('./DynamoDbVoteRepository') 5 | 6 | module.exports = { 7 | User, 8 | Session, 9 | Question, 10 | Vote 11 | } -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/ListTables.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const dynamodb = new AWS.DynamoDB(); 9 | 10 | dynamodb.listTables({Limit: 10}, function(err, data) { 11 | if (err) { 12 | console.log("Error", err.code); 13 | } else { 14 | console.log("Table names are ", data.TableNames); 15 | } 16 | }); -------------------------------------------------------------------------------- /api/src/resolver/Vote.js: -------------------------------------------------------------------------------- 1 | const QuestionRepository = require('../repository/QuestionRepository') 2 | const UserRepository = require('../repository/UserRepository') 3 | 4 | module.exports = { 5 | id: (parent) => parent.questionIdEmail, 6 | question: (parent, args, context) => QuestionRepository.getQuestion(context.repo, parent.questionId), 7 | user: (parent, args, context) => UserRepository.getUser(context.repo, parent.email) 8 | } 9 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews-node", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "bcryptjs": "^2.4.3", 8 | "graphql-yoga": "^1.18.3", 9 | "jsonwebtoken": "^8.5.1", 10 | "uuid": "^8.0.0" 11 | }, 12 | "devDependencies": { 13 | "aws-sdk": "^2.678.0", 14 | "jest": "^26.0.1", 15 | "serverless-offline": "^6.1.7" 16 | }, 17 | "scripts": { 18 | "test": "jest --maxWorkers=1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbUserRepository.js: -------------------------------------------------------------------------------- 1 | const Base = require('./DynamoDbBaseRepository') 2 | 3 | function persist (user) { 4 | const params = { 5 | TableName: "User", 6 | Item: user 7 | } 8 | return Base.persist(params) 9 | } 10 | 11 | function get (email) { 12 | const params = { 13 | TableName: "User", 14 | Key: { 15 | email: email 16 | } 17 | } 18 | return Base.get(params) 19 | } 20 | 21 | module.exports = { 22 | persist, 23 | get 24 | } -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | dynamodb: 4 | container_name: dynamodb-kuestion 5 | image: amazon/dynamodb-local 6 | entrypoint: java 7 | command: "-jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data/" 8 | restart: always 9 | volumes: 10 | - dynamodb-kuestion:/home/dynamodblocal/data/ 11 | ports: 12 | - "8000:8000" 13 | 14 | volumes: 15 | dynamodb-kuestion: 16 | external: true -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /api/src/__test__/resolver/Question.test.js: -------------------------------------------------------------------------------- 1 | const Question = require('../../resolver/Question') 2 | 3 | describe('Question type resolver', () => { 4 | it('Should resolve question fields correctly', async () => { 5 | const question = { 6 | id: 'question-0', 7 | createdAt: new Date().getTime(), 8 | text: 'Where is m mind?', 9 | voteCount: 5 10 | } 11 | 12 | expect(Question.id(question)).toBe(question.id) 13 | expect(Question.createdAt(question)).toBe(question.createdAt + "") 14 | expect(Question.text(question)).toBe(question.text) 15 | expect(Question.voteCount(question)).toEqual(question.voteCount) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /api/src/resolver/Query.js: -------------------------------------------------------------------------------- 1 | const SessionRepository = require('../repository/SessionRepository') 2 | const UserRepository = require('../repository/UserRepository') 3 | const Authentication = require('../authentication') 4 | 5 | function info () { 6 | return `This is the API of a Kuestion App` 7 | } 8 | 9 | function session (parent, { id }, context) { 10 | return SessionRepository.getSession(context.repo, id) 11 | } 12 | 13 | function user (parent, args, context) { 14 | const email = Authentication.getEmail(context) 15 | return UserRepository.getUser(context.repo, email) 16 | } 17 | 18 | module.exports = { 19 | info, 20 | session, 21 | user 22 | } 23 | -------------------------------------------------------------------------------- /api/src/authentication.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | const APP_SECRET = 'Arise, arise, Riders of Théoden! ... Ride now, ride now! Ride to Gondor!' 3 | 4 | function getEmail (context) { 5 | //console.log(context) 6 | const headers = context.request || context.event.headers 7 | const authorization = headers.authorization || headers.get('Authorization') 8 | if (authorization) { 9 | const token = authorization.replace('Bearer ', '') 10 | const { email } = jwt.verify(token, APP_SECRET) 11 | return email 12 | } 13 | 14 | throw new Error('Not authenticated') 15 | } 16 | 17 | module.exports = { 18 | APP_SECRET, 19 | getEmail 20 | } 21 | -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/DynamoDB.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | ## Jar command 3 | `java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb` 4 | 5 | ## Docker 6 | `sudo docker pull amazon/dynamodb-local` 7 | `sudo docker run -p 8000:8000 amazon/dynamodb-local` 8 | `sudo docker run -p 8000:8000 -v $(pwd)/local/dynamodb:/data/ amazon/dynamodb-local -jar DynamoDBLocal.jar -sharedDb -dbPath /data` 9 | 10 | ## Connect with AWS CLI 11 | `aws dynamodb list-tables --endpoint-url http://localhost:8000 --region local` 12 | 13 | ## NoSQL Workbench 14 | 15 | ## AWS node sdk 16 | https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GettingStarted.NodeJs.02.html 17 | -------------------------------------------------------------------------------- /api/src/__test__/resolver/User.test.js: -------------------------------------------------------------------------------- 1 | const User = require('../../resolver/User') 2 | const SessionRepository = require('../../repository/SessionRepository') 3 | 4 | describe('User type resolver', () => { 5 | it('Should resolve user fields correctly', async () => { 6 | const sessions = [{ id: 'session-0' }] 7 | SessionRepository.getSessionsOfUser = jest.fn().mockImplementation(() => sessions) 8 | 9 | const user = { 10 | name: 'alice', 11 | email: 'alice@google.com' 12 | } 13 | 14 | expect(User.name(user)).toBe(user.name) 15 | expect(User.email(user)).toBe(user.email) 16 | expect(User.sessions(user, {}, {repo:{}})).toEqual(sessions) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /api/src/repository/UserRepository.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs') 2 | 3 | async function createUser (repo, name, email, password) { 4 | // TODO email validation 5 | const hasUser = await getUser(repo, email) 6 | if (hasUser) { 7 | throw new Error('createUser - User already exists') 8 | } 9 | const hashedPassword = await bcrypt.hash(password, 10) 10 | const user = { 11 | createdAt: new Date().getTime(), 12 | name: name, 13 | email: email, 14 | password: hashedPassword 15 | } 16 | return repo.User.persist(user) 17 | } 18 | 19 | function getUser (repo, email) { 20 | return repo.User.get(email) 21 | } 22 | 23 | module.exports = { 24 | createUser, 25 | getUser 26 | } 27 | -------------------------------------------------------------------------------- /app/src/styles/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/styles/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Verdana, Geneva, sans-serif; 5 | } 6 | 7 | input { 8 | max-width: 500px; 9 | } 10 | 11 | .gray { 12 | color: #828282; 13 | } 14 | 15 | .orange { 16 | background-color: #ff6600; 17 | } 18 | 19 | .background-gray { 20 | background-color: rgb(246,246,239); 21 | } 22 | 23 | .f11 { 24 | font-size: 11px; 25 | } 26 | 27 | .w85 { 28 | width: 85%; 29 | } 30 | 31 | .button { 32 | font-family: monospace; 33 | font-size: 10pt; 34 | color: black; 35 | background-color: buttonface; 36 | text-align: center; 37 | padding: 2px 6px 3px; 38 | border-width: 2px; 39 | border-style: outset; 40 | border-color: buttonface; 41 | cursor: pointer; 42 | max-width: 250px; 43 | } -------------------------------------------------------------------------------- /api/src/handler.js: -------------------------------------------------------------------------------- 1 | const { GraphQLServerLambda } = require('graphql-yoga') 2 | const Query = require('./resolver/Query') 3 | const Mutation = require('./resolver/Mutation') 4 | const Session = require('./resolver/Session') 5 | const User = require('./resolver/User') 6 | const Question = require('./resolver/Question') 7 | const Vote = require('./resolver/Vote') 8 | const repo = require('./repository/dynamodb/DynamoDbRepository') 9 | 10 | const resolvers = { 11 | Query, 12 | Mutation, 13 | Session, 14 | User, 15 | Question, 16 | Vote 17 | } 18 | 19 | const lambda = new GraphQLServerLambda({ 20 | typeDefs: './schema.graphql', 21 | resolvers, 22 | context: request => { 23 | return { ...request, repo } 24 | } 25 | }) 26 | 27 | exports.server = lambda.graphqlHandler -------------------------------------------------------------------------------- /api/src/local.js: -------------------------------------------------------------------------------- 1 | const { GraphQLServer } = require('graphql-yoga') 2 | const Query = require('./resolver/Query') 3 | const Mutation = require('./resolver/Mutation') 4 | const Session = require('./resolver/Session') 5 | const User = require('./resolver/User') 6 | const Question = require('./resolver/Question') 7 | const Vote = require('./resolver/Vote') 8 | const repo = require('./repository/dynamodb/DynamoDbRepository') 9 | 10 | const resolvers = { 11 | Query, 12 | Mutation, 13 | Session, 14 | User, 15 | Question, 16 | Vote 17 | } 18 | 19 | const server = new GraphQLServer({ 20 | typeDefs: './src/schema.graphql', 21 | resolvers, 22 | context: request => { 23 | return { ...request, repo } 24 | } 25 | }) 26 | server.start(() => console.log(`Server is running on http://localhost:4000`)) 27 | -------------------------------------------------------------------------------- /app/src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { AUTH_TOKEN } from '../constants' 3 | import SessionList from './SessionList' 4 | 5 | class Home extends Component { 6 | render () { 7 | const authToken = localStorage.getItem(AUTH_TOKEN) 8 | return ( 9 |
10 | {authToken ? ( 11 | 12 | ) : ( 13 |
14 |

Collect Questions

15 |

For Your Live Question & Anwser Sessions

16 |
    17 |
  • Create a session
  • 18 |
  • Share it
  • 19 |
  • Add questions
  • 20 |
  • Upvote questions
  • 21 |
22 |
23 | )} 24 |
25 | ) 26 | } 27 | } 28 | 29 | export default Home 30 | -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/DeleteItem.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const docClient = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const params = { 11 | TableName: "Test", 12 | Key:{ 13 | "year": 2015, 14 | "title": "The Big New Movie" 15 | }, 16 | ConditionExpression:"info.rating <= :val", 17 | ExpressionAttributeValues: { 18 | ":val": 5.0 19 | } 20 | }; 21 | 22 | console.log("Attempting a conditional delete..."); 23 | docClient.delete(params, function(err, data) { 24 | if (err) { 25 | console.error("Unable to delete item. Error JSON:", JSON.stringify(err, null, 2)); 26 | } else { 27 | console.log("DeleteItem succeeded:", JSON.stringify(data, null, 2)); 28 | } 29 | }); -------------------------------------------------------------------------------- /api/src/__test__/resolver/Vote.test.js: -------------------------------------------------------------------------------- 1 | const Vote = require('../../resolver/Vote') 2 | const QuestionRepository = require('../../repository/QuestionRepository') 3 | const UserRepository = require('../../repository/UserRepository') 4 | 5 | describe('Vote type resolver', () => { 6 | it('Should resolve vote fields correctly', async () => { 7 | const question = { id: 'question-0' } 8 | QuestionRepository.getQuestion = jest.fn().mockImplementation(() => question) 9 | 10 | const user = { email: 'user@google.com' } 11 | UserRepository.getUser = jest.fn().mockImplementation(() => user) 12 | 13 | const vote = { 14 | questionId: 'question-0', 15 | email: 'user@google.com' 16 | } 17 | 18 | expect(Vote.question(vote, {}, {repo:{}})).toEqual(question) 19 | expect(Vote.user(vote, {}, {repo:{}})).toEqual(user) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /app/src/components/SessionItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Link } from 'react-router-dom' 3 | 4 | class SessionItem extends Component { 5 | render () { 6 | return ( 7 |
8 |
9 | {this.props.index + 1}. 10 |
11 |
12 |
13 | 14 | {this.props.item.title} 15 | 16 |
17 |
18 | {this.props.item.totalQuestionCount} questions | {this.props.item.totalVoteCount} votes 19 |
20 |
21 |
22 | ) 23 | } 24 | } 25 | 26 | export default SessionItem 27 | -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/AtomicUpdateItem.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const docClient = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const params = { 11 | TableName: "Test", 12 | Key:{ 13 | "year": 2015, 14 | "title": "The Big New Movie" 15 | }, 16 | UpdateExpression: "set info.rating = info.rating + :val", 17 | ExpressionAttributeValues:{ 18 | ":val": 1 19 | }, 20 | ReturnValues:"UPDATED_NEW" 21 | }; 22 | 23 | console.log("Updating the item..."); 24 | docClient.update(params, function(err, data) { 25 | if (err) { 26 | console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2)); 27 | } else { 28 | console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2)); 29 | } 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /app/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import CreateSession from './CreateSession' 3 | import Home from './Home' 4 | import Header from './Header' 5 | import Login from './Login' 6 | import Session from './Session' 7 | import { Switch, Route } from 'react-router-dom' 8 | 9 | class App extends Component { 10 | render () { 11 | return ( 12 |
13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ) 24 | } 25 | } 26 | 27 | export default App 28 | -------------------------------------------------------------------------------- /api/src/resolver/Session.js: -------------------------------------------------------------------------------- 1 | const UserRepository = require('../repository/UserRepository') 2 | const QuestionRepository = require('../repository/QuestionRepository') 3 | const VoteRepository = require('../repository/VoteRepository') 4 | 5 | module.exports = { 6 | id: (parent) => parent.id, 7 | createdAt: (parent) => parent.createdAt + "", 8 | title: (parent) => parent.title, 9 | description: (parent) => parent.description, 10 | postedBy: (parent, args, context) => { 11 | return UserRepository.getUser(context.repo, parent.email) 12 | }, 13 | questions: (parent, args, context) => { 14 | return QuestionRepository.getQuestionsOfSession(context.repo, parent.id) 15 | }, 16 | votesOfCurrentUser: (parent, args, context) => { 17 | return VoteRepository.getSessionVotesOfCurrentUser(context.repo, parent.id, parent.email) 18 | }, 19 | totalQuestionCount: (parent) => parent.totalQuestionCount, 20 | totalVoteCount: (parent) => parent.totalVoteCount 21 | } 22 | -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/UpdateItem.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const docClient = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const params = { 11 | TableName: "Test", 12 | Key:{ 13 | "year": 2015, 14 | "title": "The Big New Movie" 15 | }, 16 | UpdateExpression: "set info.rating = :r, info.plot=:p, info.actors=:a", 17 | ExpressionAttributeValues:{ 18 | ":r":5.5, 19 | ":p":"Everything happens all at once.", 20 | ":a":["Larry", "Moe", "Curly"] 21 | }, 22 | ReturnValues:"UPDATED_NEW" 23 | }; 24 | 25 | console.log("Updating the item..."); 26 | docClient.update(params, function(err, data) { 27 | if (err) { 28 | console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2)); 29 | } else { 30 | console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2)); 31 | } 32 | }); -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/CreateTables.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const dynamodb = new AWS.DynamoDB(); 9 | 10 | const params = { 11 | TableName : "Test", 12 | KeySchema: [ 13 | { AttributeName: "year", KeyType: "HASH"}, //Partition key 14 | { AttributeName: "title", KeyType: "RANGE" } //Sort key 15 | ], 16 | AttributeDefinitions: [ 17 | { AttributeName: "year", AttributeType: "N" }, 18 | { AttributeName: "title", AttributeType: "S" } 19 | ], 20 | ProvisionedThroughput: { 21 | ReadCapacityUnits: 2, 22 | WriteCapacityUnits: 1 23 | } 24 | }; 25 | 26 | dynamodb.createTable(params, function(err, data) { 27 | if (err) { 28 | console.error("Unable to create table. Error JSON:", JSON.stringify(err, null, 2)); 29 | } else { 30 | console.log("Created table. Table description JSON:", JSON.stringify(data, null, 2)); 31 | } 32 | }); -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/ReadItem.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const docClient = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const params = { 11 | TableName: "Test", 12 | Key:{ 13 | "year": 2015, 14 | "title": "The Big New Movie" 15 | } 16 | }; 17 | 18 | async function get(params) { 19 | try { 20 | const data = await docClient.get(params).promise(); 21 | console.log("GetItem succeeded:", JSON.stringify(data, null, 2)); 22 | return data 23 | } catch (err) { 24 | console.error("Unable to read item. Error JSON:", JSON.stringify(err, null, 2)); 25 | } 26 | } 27 | 28 | get(params); 29 | 30 | /* 31 | docClient.get(params, function(err, data) { 32 | if (err) { 33 | console.error("Unable to read item. Error JSON:", JSON.stringify(err, null, 2)); 34 | } else { 35 | console.log("GetItem succeeded:", JSON.stringify(data, null, 2)); 36 | } 37 | }); 38 | */ -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbVoteRepository.js: -------------------------------------------------------------------------------- 1 | const Base = require('./DynamoDbBaseRepository') 2 | 3 | function persist (vote) { 4 | vote.questionIdEmail = `${vote.questionId}-${vote.email}` 5 | vote.sessionIdEmail = `${vote.sessionId}-${vote.email}` 6 | 7 | const params = { 8 | TableName: "Vote", 9 | Item: vote 10 | } 11 | return Base.persist(params) 12 | } 13 | 14 | function get (questionId, email) { 15 | const params = { 16 | TableName: "Vote", 17 | Key: { 18 | questionIdEmail: `${questionId}-${email}` 19 | } 20 | } 21 | return Base.get(params) 22 | } 23 | 24 | function getSessionVotesOfCurrentUser (sessionId, userId) { 25 | const sessionIdEmail = `${vote.sessionId}-${vote.email}` 26 | 27 | const params = { 28 | TableName: "Vote", 29 | IndexName: "SessionIdEmailIndex", 30 | KeyConditionExpression: "sessionIdEmail = :sie", 31 | ExpressionAttributeValues: { 32 | ":sie": sessionIdEmail 33 | } 34 | } 35 | return Base.query(params) 36 | } 37 | 38 | module.exports = { 39 | persist, 40 | get, 41 | getSessionVotesOfCurrentUser 42 | } -------------------------------------------------------------------------------- /api/src/__test__/resolver/Query.test.js: -------------------------------------------------------------------------------- 1 | const Query = require('../../resolver/Query') 2 | const SessionRepository = require('../../repository/SessionRepository') 3 | const UserRepository = require('../../repository/UserRepository') 4 | const Authentication = require('../../authentication') 5 | 6 | describe('Query type resolver', () => { 7 | it('Should resolve session', async () => { 8 | const session = { id: 'session-0' } 9 | SessionRepository.getSession = jest.fn().mockImplementationOnce(() => session) 10 | 11 | expect(Query.session({}, { id: 'session-0' }, { repo: {} })).toEqual(session) 12 | }) 13 | 14 | it('Should resolve user when logged in', async () => { 15 | const user = { id: 'user-0' } 16 | UserRepository.getUser = jest.fn().mockImplementationOnce(() => user) 17 | Authentication.getEmail = jest.fn().mockImplementationOnce(() => 'user@google.com') 18 | 19 | expect(Query.user({}, {}, { repo: {} })).toEqual(user) 20 | }) 21 | 22 | it('Should return no value when nobody logged in', async () => { 23 | expect(Query.user({}, {}, { repo: {} })).toEqual(undefined) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews-react-apollo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "apollo-boost": "^0.4.8", 10 | "apollo-link-context": "^1.0.20", 11 | "graphql": "^15.0.0", 12 | "react": "^16.13.1", 13 | "react-apollo": "^3.1.5", 14 | "react-dom": "^16.13.1", 15 | "react-router": "^5.2.0", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "3.4.1" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject" 24 | }, 25 | "eslintConfig": { 26 | "extends": "react-app" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/src/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | info: String! 3 | session(id: ID!): Session 4 | user: User 5 | } 6 | 7 | type Mutation { 8 | createSession(title: String!, description: String!): Session! 9 | updateSession(id: ID!, title: String, description: String): Session 10 | 11 | createQuestion(text: String!, sessionId: ID!): Question! 12 | 13 | signup(name: String!, email: String!, password: String!): AuthPayload 14 | login(email: String!, password: String!): AuthPayload 15 | 16 | vote(questionId: ID!, sessionId: ID!): Vote 17 | } 18 | 19 | type Session { 20 | id: ID! 21 | createdAt: String! 22 | title: String! 23 | description: String! 24 | postedBy: User! 25 | questions: [Question!]! 26 | votesOfCurrentUser: [ID!]! 27 | totalQuestionCount: Int! 28 | totalVoteCount: Int! 29 | } 30 | 31 | type User { 32 | email: String! 33 | name: String! 34 | sessions: [Session!]! 35 | } 36 | 37 | type Question { 38 | id: ID! 39 | createdAt: String! 40 | text: String! 41 | voteCount: Int! 42 | } 43 | 44 | type Vote { 45 | id: ID! 46 | question: Question! 47 | user: User! 48 | } 49 | 50 | type AuthPayload { 51 | token: String 52 | user: User 53 | } -------------------------------------------------------------------------------- /api/src/data/dynamodb/samples/CreateItem.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }); 7 | 8 | const docClient = new AWS.DynamoDB.DocumentClient(); 9 | 10 | const params = { 11 | TableName: "Test", 12 | Item:{ 13 | "year": 2018, 14 | "title": "The Big New Movie", 15 | "info":{ 16 | "plot": "Nothing happens at all.", 17 | "rating": 0 18 | } 19 | } 20 | }; 21 | 22 | console.log("Adding a new item..."); 23 | 24 | async function persist (params) { 25 | try { 26 | const data = await docClient.put(params).promise() 27 | console.log("Added item:", JSON.stringify(data, null, 2)); 28 | return data 29 | } catch (err) { 30 | console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2)); 31 | } 32 | } 33 | 34 | persist(params); 35 | 36 | /* 37 | docClient.put(params, function(err, data) { 38 | if (err) { 39 | console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2)); 40 | } else { 41 | console.log("Added item:", JSON.stringify(data, null, 2)); 42 | } 43 | }); 44 | */ -------------------------------------------------------------------------------- /api/src/repository/QuestionRepository.js: -------------------------------------------------------------------------------- 1 | const SessionRepository = require('./SessionRepository') 2 | 3 | async function createQuestion (repo, sessionId, postedBy, text) { 4 | if (!postedBy) { 5 | throw new Error('createQuestion - No user found') 6 | } 7 | const hasSession = await SessionRepository.getSession(repo, sessionId) 8 | if (!hasSession) { 9 | throw new Error('createQuestion - No session found') 10 | } 11 | // TODO string.notblank 12 | 13 | const question = { 14 | sessionId: sessionId, 15 | email: postedBy, 16 | createdAt: new Date().getTime(), 17 | text: text, 18 | voteCount: 0 19 | } 20 | const result = await repo.Question.persist(question) 21 | 22 | SessionRepository.incrementTotalQuestionCount(repo, sessionId) 23 | 24 | return result 25 | } 26 | 27 | function getQuestion (repo, id) { 28 | return repo.Question.get(id) 29 | } 30 | 31 | function getQuestionsOfSession (repo, sessionId) { 32 | return repo.Question.getQuestionsOfSession(sessionId) 33 | } 34 | 35 | function incrementVoteCount (repo, id) { 36 | return repo.Question.incrementVoteCount(id) 37 | } 38 | 39 | module.exports = { 40 | createQuestion, 41 | getQuestion, 42 | getQuestionsOfSession, 43 | incrementVoteCount 44 | } -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { BrowserRouter } from 'react-router-dom' 4 | import './styles/index.css' 5 | import App from './components/App' 6 | import * as serviceWorker from './serviceWorker' 7 | 8 | import { ApolloProvider } from 'react-apollo' 9 | import { ApolloClient } from 'apollo-client' 10 | import { createHttpLink } from 'apollo-link-http' 11 | import { InMemoryCache } from 'apollo-cache-inmemory' 12 | import { setContext } from 'apollo-link-context' 13 | 14 | import { AUTH_TOKEN } from './constants' 15 | 16 | const httpLink = createHttpLink({ 17 | uri: process.env.REACT_APP_API_URL 18 | }) 19 | 20 | const authLink = setContext((_, { headers }) => { 21 | const token = localStorage.getItem(AUTH_TOKEN) 22 | return { 23 | headers: { 24 | ...headers, 25 | authorization: token ? `Bearer ${token}` : '' 26 | } 27 | } 28 | }) 29 | 30 | export const client = new ApolloClient({ 31 | link: authLink.concat(httpLink), 32 | cache: new InMemoryCache() 33 | }) 34 | 35 | ReactDOM.render( 36 | 37 | 38 | 39 | 40 | , 41 | document.getElementById('root') 42 | ) 43 | serviceWorker.unregister() 44 | -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbQuestionRepository.js: -------------------------------------------------------------------------------- 1 | const Base = require('./DynamoDbBaseRepository') 2 | const { v4: uuidv4 } = require('uuid') 3 | 4 | function persist (question) { 5 | question.id = uuidv4() 6 | 7 | const params = { 8 | TableName: "Question", 9 | Item: question 10 | } 11 | return Base.persist(params) 12 | } 13 | 14 | function get (id) { 15 | const params = { 16 | TableName: "Question", 17 | Key: { 18 | id: id 19 | } 20 | } 21 | return Base.get(params) 22 | } 23 | 24 | function getQuestionsOfSession (sessionId) { 25 | const params = { 26 | TableName: "Question", 27 | IndexName: "SessionIdIndex", 28 | KeyConditionExpression: "sessionId = :s", 29 | ExpressionAttributeValues: { 30 | ":s": sessionId 31 | } 32 | } 33 | return Base.query(params) 34 | } 35 | 36 | function incrementVoteCount (id) { 37 | var params = { 38 | TableName: "Question", 39 | Key:{ 40 | "id": id 41 | }, 42 | UpdateExpression: "set voteCount = voteCount + :val", 43 | ExpressionAttributeValues:{ 44 | ":val": 1 45 | }, 46 | ReturnValues:"UPDATED_NEW" 47 | } 48 | return Base.update(params) 49 | } 50 | 51 | module.exports = { 52 | persist, 53 | get, 54 | getQuestionsOfSession, 55 | incrementVoteCount 56 | } -------------------------------------------------------------------------------- /app/src/components/SessionList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import SessionItem from './SessionItem' 3 | import { Query } from 'react-apollo' 4 | import gql from 'graphql-tag' 5 | 6 | export const SESSION_LIST_QUERY = gql` 7 | { 8 | user { 9 | sessions { 10 | id 11 | title 12 | description 13 | createdAt 14 | totalQuestionCount 15 | totalVoteCount 16 | } 17 | } 18 | } 19 | ` 20 | 21 | class SessionList extends Component { 22 | render () { 23 | return ( 24 | 25 | {({ loading, error, data }) => { 26 | if (loading) return
Fetching
27 | if (error) return
Error
28 | 29 | const itemsToRender = data.user ? data.user.sessions : [] 30 | 31 | return ( 32 |
33 | {itemsToRender.map((item, index) => ( 34 | 38 | ))} 39 | {itemsToRender.length === 0 && ( 40 |

You don't have any session yet.

41 | )} 42 |
43 | ) 44 | }} 45 |
46 | ) 47 | } 48 | } 49 | 50 | export default SessionList 51 | -------------------------------------------------------------------------------- /api/src/repository/SessionRepository.js: -------------------------------------------------------------------------------- 1 | function createSession (repo, postedBy, title, description) { 2 | if (!postedBy) { 3 | throw new Error('createSession - No user found') 4 | } 5 | 6 | const session = { 7 | email: postedBy, 8 | createdAt: new Date().getTime(), 9 | title: title, 10 | description: description, 11 | totalQuestionCount: 0, 12 | totalVoteCount: 0 13 | } 14 | 15 | return repo.Session.persist(session) 16 | } 17 | 18 | function getSession (repo, id) { 19 | return repo.Session.get(id) 20 | } 21 | 22 | async function updateSession (repo, id, updatedBy, title, description) { 23 | const session = await getSession(repo, id) 24 | if (!session) { 25 | throw new Error('updateSession - No session found') 26 | } 27 | if (session.email !== updatedBy) { 28 | throw new Error('updateSession - Not allowed') 29 | } 30 | 31 | session.title = title || session.title 32 | session.description = description || session.description 33 | 34 | return repo.Session.update(session) 35 | } 36 | 37 | function getSessionsOfUser (repo, email) { 38 | return repo.Session.getSessionsOfUser(email) 39 | } 40 | 41 | function incrementTotalQuestionCount (repo, id) { 42 | return repo.Session.incrementTotalQuestionCount(id) 43 | } 44 | 45 | function incrementTotalVoteCount (repo, id) { 46 | return repo.Session.incrementTotalVoteCount(id) 47 | } 48 | 49 | module.exports = { 50 | getSession, 51 | createSession, 52 | updateSession, 53 | getSessionsOfUser, 54 | incrementTotalQuestionCount, 55 | incrementTotalVoteCount 56 | } -------------------------------------------------------------------------------- /api/src/repository/VoteRepository.js: -------------------------------------------------------------------------------- 1 | const QuestionRepository = require('./QuestionRepository') 2 | const SessionRepository = require('./SessionRepository') 3 | 4 | async function createVote (repo, questionId, sessionId, postedBy) { 5 | if (!postedBy) { 6 | throw new Error('createVote - No user found', postedBy) 7 | } 8 | const hasQuestion = await QuestionRepository.getQuestion(repo, questionId) 9 | if (!hasQuestion) { 10 | throw new Error('createVote - No question found', questionId) 11 | } 12 | const hasSession = await SessionRepository.getSession(repo, sessionId) 13 | if (!hasSession) { 14 | throw new Error('createVote - No session found', sessionId) 15 | } 16 | 17 | const hasVote = await getVote(repo, questionId, postedBy) 18 | if (hasVote) { 19 | throw new Error(`createVote - Already voted`, questionId, postedBy) 20 | } 21 | 22 | const vote = { 23 | questionId: questionId, 24 | sessionId: sessionId, 25 | email: postedBy, 26 | createdAt: new Date().getTime() 27 | } 28 | const result = await repo.Vote.persist(vote) 29 | 30 | QuestionRepository.incrementVoteCount(repo, questionId) 31 | SessionRepository.incrementTotalVoteCount(repo, sessionId) 32 | 33 | return result 34 | } 35 | 36 | function getVote (repo, questionId, email) { 37 | return repo.Vote.get(questionId, email) 38 | } 39 | 40 | function getSessionVotesOfCurrentUser (repo, sessionId, userId) { 41 | return repo.Vote.getSessionVotesOfCurrentUser(sessionId, userId).map(v => v.questionId) 42 | } 43 | 44 | module.exports = { 45 | createVote, 46 | getSessionVotesOfCurrentUser 47 | } -------------------------------------------------------------------------------- /app/src/components/LinkList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Link from './Link' 3 | import { Query } from 'react-apollo' 4 | import gql from 'graphql-tag' 5 | 6 | export const LINKS_QUERY = gql` 7 | { 8 | links { 9 | id 10 | url 11 | description 12 | postedBy { 13 | id 14 | name 15 | } 16 | votes { 17 | id 18 | user { 19 | id 20 | } 21 | } 22 | } 23 | } 24 | ` 25 | 26 | class LinkList extends Component { 27 | _updateCacheAfterVote = (store, createVote, linkId) => { 28 | const data = store.readQuery({ query: LINKS_QUERY }) 29 | 30 | const votedLink = data.feed.links.find(link => link.id === linkId) 31 | votedLink.votes = createVote.link.votes 32 | 33 | store.writeQuery({ query: LINKS_QUERY, data }) 34 | } 35 | 36 | render () { 37 | return ( 38 | 39 | {({ loading, error, data }) => { 40 | if (loading) return
Fetching
41 | if (error) return
Error
42 | 43 | const linksToRender = data.links 44 | 45 | return ( 46 |
47 | {linksToRender.map((link, index) => ( 48 | 53 | ))} 54 |
55 | ) 56 | }} 57 |
58 | ) 59 | } 60 | } 61 | 62 | export default LinkList 63 | -------------------------------------------------------------------------------- /api/src/__test__/resolver/Session.test.js: -------------------------------------------------------------------------------- 1 | const Session = require('../../resolver/Session') 2 | const UserRepository = require('../../repository/UserRepository') 3 | const QuestionRepository = require('../../repository/QuestionRepository') 4 | const VoteRepository = require('../../repository/VoteRepository') 5 | 6 | describe('Session type resolver', () => { 7 | it('Should resolve session fields correctly', async () => { 8 | const questions = [{ id: 'question-0' }] 9 | QuestionRepository.getQuestionsOfSession = jest.fn().mockImplementation(() => questions) 10 | 11 | const user = { email: 'user@google.com' } 12 | UserRepository.getUser = jest.fn().mockImplementation(() => user) 13 | 14 | const votes = [{ id: 'question-0-user@google.com' }] 15 | VoteRepository.getSessionVotesOfCurrentUser = jest.fn().mockImplementation(() => votes) 16 | 17 | const session = { 18 | id: 'session-0', 19 | createdAt: new Date().getTime(), 20 | title: 'title', 21 | description: 'desc', 22 | email: 'user@google.com' 23 | } 24 | 25 | expect(Session.id(session)).toBe(session.id) 26 | expect(Session.createdAt(session)).toBe(session.createdAt + "") 27 | expect(Session.title(session)).toBe(session.title) 28 | expect(Session.description(session)).toBe(session.description) 29 | expect(Session.postedBy(session, {}, {repo:{}})).toEqual(user) 30 | expect(Session.questions(session, {}, {repo:{}})).toEqual(questions) 31 | expect(Session.votesOfCurrentUser(session, {}, {repo:{}})).toEqual(votes) 32 | expect(Session.totalQuestionCount(session)).toEqual(session.totalQuestionCount) 33 | expect(Session.totalVoteCount(session)).toEqual(session.totalVoteCount) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /app/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { withRouter } from 'react-router' 4 | import { AUTH_TOKEN, EMAIL } from '../constants' 5 | import { client } from '../index' 6 | 7 | class Header extends Component { 8 | render () { 9 | const authToken = localStorage.getItem(AUTH_TOKEN) 10 | const email = localStorage.getItem(EMAIL) 11 | return ( 12 |
13 |
14 |
KUESTION
15 | 16 | home 17 | 18 | {authToken && ( 19 |
20 |
|
21 | 22 | create session 23 | 24 |
25 | )} 26 |
27 |
28 | {authToken ? ( 29 |
30 |
{email}
31 |
|
32 |
{ 35 | client.resetStore() 36 | localStorage.removeItem(AUTH_TOKEN) 37 | localStorage.removeItem(EMAIL) 38 | this.props.history.push(`/`) 39 | }} 40 | > 41 | logout 42 |
43 |
44 | ) : ( 45 | 46 | login 47 | 48 | )} 49 |
50 |
51 | ) 52 | } 53 | } 54 | 55 | export default withRouter(Header) 56 | -------------------------------------------------------------------------------- /app/src/components/CreateLink.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Mutation } from 'react-apollo' 3 | import gql from 'graphql-tag' 4 | import { LINKS_QUERY } from './LinkList' 5 | 6 | const CREATE_LINK_MUTATION = gql` 7 | mutation CreateLinkMutation($description: String!, $url: String!) { 8 | createLink(description: $description, url: $url) { 9 | id 10 | url 11 | description 12 | } 13 | } 14 | ` 15 | 16 | class CreateLink extends Component { 17 | state = { 18 | description: '', 19 | url: '', 20 | } 21 | 22 | render() { 23 | const { description, url } = this.state 24 | return ( 25 |
26 |
27 | this.setState({ description: e.target.value })} 31 | type="text" 32 | placeholder="A description for the link" 33 | /> 34 | this.setState({ url: e.target.value })} 38 | type="text" 39 | placeholder="The URL for the link" 40 | /> 41 |
42 | this.props.history.push('/')} 46 | update={(store, { data: { post } }) => { 47 | const data = store.readQuery({ query: LINKS_QUERY }) 48 | data.feed.links.unshift(post) 49 | store.writeQuery({ 50 | query: LINKS_QUERY, 51 | data 52 | }) 53 | }} 54 | > 55 | {createLinkMutation => } 56 | 57 |
58 | ) 59 | } 60 | } 61 | 62 | export default CreateLink -------------------------------------------------------------------------------- /app/src/components/Link.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { AUTH_TOKEN } from '../constants' 3 | import { Mutation } from 'react-apollo' 4 | import gql from 'graphql-tag' 5 | 6 | const VOTE_MUTATION = gql` 7 | mutation VoteMutation($linkId: ID!) { 8 | vote(linkId: $linkId) { 9 | id 10 | link { 11 | id 12 | votes { 13 | id 14 | user { 15 | id 16 | } 17 | } 18 | } 19 | user { 20 | id 21 | } 22 | } 23 | } 24 | ` 25 | 26 | class Link extends Component { 27 | render () { 28 | const authToken = localStorage.getItem(AUTH_TOKEN) 29 | return ( 30 |
31 |
32 | {this.props.index + 1}. 33 | {authToken && ( 34 | 38 | this.props.updateStoreAfterVote(store, vote, this.props.link.id) 39 | } 40 | onError={() => {}} 41 | > 42 | {voteMutation => ( 43 |
44 | ▲ 45 |
46 | )} 47 |
48 | )} 49 |
50 |
51 |
52 | {this.props.link.description} ({this.props.link.url}) 53 |
54 |
55 | {this.props.link.votes.length} votes | by{' '} 56 | {this.props.link.postedBy 57 | ? this.props.link.postedBy.name 58 | : 'Unknown'}{' '} 59 |
60 |
61 |
62 | ) 63 | } 64 | } 65 | 66 | export default Link 67 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbSessionRepository.js: -------------------------------------------------------------------------------- 1 | const Base = require('./DynamoDbBaseRepository') 2 | const { v4: uuidv4 } = require('uuid') 3 | 4 | function persist (session) { 5 | session.id = uuidv4() 6 | 7 | const params = { 8 | TableName: "Session", 9 | Item: session 10 | } 11 | return Base.persist(params) 12 | } 13 | 14 | function get (id) { 15 | const params = { 16 | TableName: "Session", 17 | Key: { 18 | id: id 19 | } 20 | } 21 | return Base.get(params) 22 | } 23 | 24 | function getSessionsOfUser (email) { 25 | const params = { 26 | TableName: "Session", 27 | IndexName: "EmailIndex", 28 | KeyConditionExpression: "email = :e", 29 | ExpressionAttributeValues: { 30 | ":e": email 31 | } 32 | } 33 | return Base.query(params) 34 | } 35 | 36 | function update (session) { 37 | const params = { 38 | TableName: "Session", 39 | Key: { 40 | "id": session.id 41 | }, 42 | UpdateExpression: "set title=:t, description=:d", 43 | ExpressionAttributeValues: { 44 | ":t": session.title, 45 | ":d": session.description 46 | }, 47 | ReturnValues:"UPDATED_NEW" 48 | } 49 | return Base.update(params) 50 | } 51 | 52 | function incrementTotalQuestionCount (id) { 53 | var params = { 54 | TableName: "Session", 55 | Key:{ 56 | "id": id 57 | }, 58 | UpdateExpression: "set totalQuestionCount = totalQuestionCount + :val", 59 | ExpressionAttributeValues:{ 60 | ":val": 1 61 | }, 62 | ReturnValues:"UPDATED_NEW" 63 | } 64 | return Base.update(params) 65 | } 66 | 67 | 68 | function incrementTotalVoteCount (id) { 69 | var params = { 70 | TableName: "Session", 71 | Key:{ 72 | "id": id 73 | }, 74 | UpdateExpression: "set totalVoteCount = totalVoteCount + :val", 75 | ExpressionAttributeValues:{ 76 | ":val": 1 77 | }, 78 | ReturnValues:"UPDATED_NEW" 79 | } 80 | return Base.update(params) 81 | } 82 | 83 | module.exports = { 84 | persist, 85 | get, 86 | getSessionsOfUser, 87 | update, 88 | incrementTotalQuestionCount, 89 | incrementTotalVoteCount 90 | } -------------------------------------------------------------------------------- /api/src/repository/dynamodb/DynamoDbBaseRepository.js: -------------------------------------------------------------------------------- 1 | const Client = require('./DynamoDbClient') 2 | 3 | async function get (params) { 4 | try { 5 | const data = await Client.docClient.get(params).promise() 6 | console.log("GetItem succeeded:", JSON.stringify(data, null, 2)) 7 | return data.Item 8 | } catch (err) { 9 | console.error("Unable to read item. Error JSON:", JSON.stringify(err, null, 2)) 10 | throw new Error("Unable to read item") 11 | } 12 | } 13 | 14 | async function persist (params) { 15 | try { 16 | const data = await Client.docClient.put(params).promise() 17 | console.log("Added item:", JSON.stringify(data, null, 2)) 18 | return params.Item 19 | } catch (err) { 20 | console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2)) 21 | throw new Error("Unable to add item") 22 | } 23 | } 24 | 25 | async function update (params) { 26 | try { 27 | const data = await Client.docClient.update(params).promise() 28 | console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2)) 29 | return params.Item 30 | } catch (err) { 31 | console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2)) 32 | throw new Error("Unable to update item") 33 | } 34 | } 35 | 36 | async function scan (params) { 37 | try { 38 | const data = await Client.docClient.scan(params).promise() 39 | console.log("Scan result:", JSON.stringify(data, null, 2)) 40 | return data.Items 41 | } catch (err) { 42 | console.error("Unable to scan the table. Error JSON:", JSON.stringify(err, null, 2)); 43 | throw new Error("Unable to scan the table") 44 | } 45 | } 46 | 47 | async function query (params) { 48 | console.log(params) 49 | try { 50 | const data = await Client.docClient.query(params).promise() 51 | console.log("Query result:", JSON.stringify(data, null, 2)) 52 | return data.Items 53 | } catch (err) { 54 | console.error("Unable to query the table. Error JSON:", JSON.stringify(err, null, 2)); 55 | throw new Error("Unable to query the table") 56 | } 57 | } 58 | 59 | module.exports = { 60 | get, 61 | persist, 62 | update, 63 | scan, 64 | query 65 | } -------------------------------------------------------------------------------- /app/src/components/CreateSession.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Mutation } from 'react-apollo' 3 | import gql from 'graphql-tag' 4 | import { SESSION_LIST_QUERY } from './SessionList' 5 | 6 | const CREATE_SESSION_MUTATION = gql` 7 | mutation CreateSessionMutation($title: String!, $description: String!) { 8 | createSession(title: $title, description: $description) { 9 | id 10 | title 11 | description 12 | createdAt 13 | } 14 | } 15 | ` 16 | 17 | class CreateSession extends Component { 18 | state = { 19 | title: '', 20 | description: '' 21 | } 22 | 23 | render() { 24 | const { title, description } = this.state 25 | return ( 26 |
27 |
28 | this.setState({ title: e.target.value })} 32 | type="text" 33 | placeholder="Title of the session" 34 | /> 35 | this.setState({ description: e.target.value })} 39 | type="text" 40 | placeholder="Description of the session" 41 | /> 42 |
43 | this.props.history.push('/')} 47 | update={(store, { data: { createSession } }) => { 48 | this._updateSessionListData(store, createSession) 49 | }} 50 | > 51 | {createSessionMutation => } 52 | 53 |
54 | ) 55 | } 56 | 57 | _updateSessionListData (store, createSession) { 58 | const data = store.readQuery({ query: SESSION_LIST_QUERY }) 59 | if (data.user) { 60 | data.user.sessions.push(createSession) 61 | store.writeQuery({ 62 | query: SESSION_LIST_QUERY, 63 | data 64 | }) 65 | } 66 | } 67 | } 68 | 69 | export default CreateSession -------------------------------------------------------------------------------- /app/src/components/CreateQuestion.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Mutation } from 'react-apollo' 3 | import gql from 'graphql-tag' 4 | import { SESSION_QUERY } from './Session' 5 | import { SESSION_LIST_QUERY } from './SessionList' 6 | 7 | const CREATE_QUESTION_MUTATION = gql` 8 | mutation CreateQuestionMutation($text: String!, $sessionId: ID!) { 9 | createQuestion(text: $text, sessionId: $sessionId) { 10 | id 11 | createdAt 12 | text 13 | voteCount 14 | } 15 | } 16 | ` 17 | 18 | class CreateQuestion extends Component { 19 | state = { 20 | text: '' 21 | } 22 | 23 | render() { 24 | const sessionId = this.props.sessionId 25 | const { text } = this.state 26 | return ( 27 |
28 |
29 | this.setState({ text: e.target.value })} 33 | type="text" 34 | placeholder="Ask question" 35 | /> 36 |
37 | { 41 | this.setState({ text: '' }) 42 | }} 43 | update={(store, { data: { createQuestion } }) => { 44 | this._updateSessionData(store, createQuestion, sessionId) 45 | this._updateSessionListData(store, sessionId) 46 | }} 47 | > 48 | {createQuestionMutation => } 49 | 50 |
51 | ) 52 | } 53 | 54 | _updateSessionData (store, createQuestion, sessionId) { 55 | const queryData = { query: SESSION_QUERY, variables: { id: sessionId } } 56 | const data = store.readQuery(queryData) 57 | data.session.questions.push(createQuestion) 58 | store.writeQuery({ 59 | query: SESSION_QUERY, 60 | variables: { id: sessionId }, 61 | data 62 | }) 63 | 64 | this.props.updateQuestions(data.session.questions) 65 | } 66 | 67 | _updateSessionListData (store, sessionId) { 68 | const queryData = { query: SESSION_LIST_QUERY } 69 | const data = store.readQuery(queryData) 70 | const session = data.user.sessions.find(s => s.id === sessionId) 71 | if (session) { 72 | session.totalQuestionCount += 1 73 | 74 | store.writeQuery({ 75 | query: SESSION_LIST_QUERY, 76 | data 77 | }) 78 | } 79 | } 80 | } 81 | 82 | export default CreateQuestion -------------------------------------------------------------------------------- /app/src/components/Session.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import CreateQuestion from './CreateQuestion' 3 | import QuestionItem from './QuestionItem' 4 | import { Query } from 'react-apollo' 5 | import gql from 'graphql-tag' 6 | 7 | export const SESSION_QUERY = gql` 8 | query Session ($id: ID!) { 9 | session(id: $id) { 10 | id 11 | title 12 | description 13 | createdAt 14 | postedBy { 15 | name 16 | } 17 | questions { 18 | id 19 | createdAt 20 | text 21 | voteCount 22 | } 23 | } 24 | } 25 | ` 26 | 27 | class Session extends Component { 28 | constructor(props) { 29 | super(props) 30 | 31 | this.updateQuestions = this.updateQuestions.bind(this) 32 | } 33 | 34 | updateQuestions(questions) { 35 | this.setState({ 36 | questions: questions 37 | }) 38 | } 39 | 40 | state = { 41 | sessionExist: false, 42 | questions: [] 43 | } 44 | 45 | render () { 46 | const { id } = this.props.match.params 47 | const { sessionExist, questions } = this.state 48 | 49 | return ( 50 |
51 | this.setState({ 55 | sessionExist: data.session !== null, 56 | questions: data.session ? data.session.questions : [] 57 | })} 58 | > 59 | {({ loading, error, data }) => { 60 | if (loading) return
Fetching
61 | if (error) return
Error
62 | return (sessionExist && 63 |
64 |

{data.session.title}

65 |

by {data.session.postedBy.name} | z time ago

66 |

{data.session.description}

67 |
68 | ) 69 | }} 70 |
71 | 72 | {sessionExist ? 73 | ( 74 |
75 | {questions.map((item, index) => ( 76 | 82 | ))} 83 | 84 | 87 |
88 | ) : ( 89 |
No such session
90 | )} 91 |
92 | ) 93 | } 94 | } 95 | 96 | export default Session -------------------------------------------------------------------------------- /app/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /api/src/resolver/Mutation.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs') 2 | const jwt = require('jsonwebtoken') 3 | const Authentication = require('../authentication') 4 | 5 | const SessionRepository = require('../repository/SessionRepository') 6 | const UserRepository = require('../repository/UserRepository') 7 | const QuestionRepository = require('../repository/QuestionRepository') 8 | const VoteRepository = require('../repository/VoteRepository') 9 | 10 | function createSession (parent, { title, description }, context) { 11 | const postedBy = Authentication.getEmail(context) 12 | return SessionRepository.createSession(context.repo, postedBy, title, description) 13 | } 14 | 15 | function updateSession (parent, { id, title, description }, context) { 16 | const updatedBy = Authentication.getEmail(context) 17 | return SessionRepository.updateSession(context.repo, id, updatedBy, title, description) 18 | } 19 | 20 | function createQuestion (parent, { text, sessionId }, context) { 21 | const postedBy = Authentication.getEmail(context) 22 | return QuestionRepository.createQuestion(context.repo, sessionId, postedBy, text) 23 | } 24 | 25 | async function signup (parent, { name, email, password }, context) { 26 | const Authorization = context.request.get('Authorization') 27 | if (Authorization) { 28 | throw new Error('signup - Already logged in!') 29 | } 30 | 31 | const user = await UserRepository.createUser(context.repo, name, email, password) 32 | const token = jwt.sign({ email: user.email }, Authentication.APP_SECRET) 33 | 34 | return { 35 | token, 36 | user 37 | } 38 | } 39 | 40 | async function login (parent, { email, password }, context) { 41 | const Authorization = context.request.get('Authorization') 42 | if (Authorization) { 43 | throw new Error('login - Already logged in!') 44 | } 45 | 46 | const user = await UserRepository.getUser(context.repo, email) 47 | if (!user.email) { 48 | console.log(`No such user found for ${email}`) 49 | throw new Error('login - Invalid username or password') 50 | } 51 | 52 | const valid = await bcrypt.compare(password, user.password) 53 | if (!valid) { 54 | console.log(`Invalid password for ${email}`) 55 | throw new Error('login - Invalid username or password') 56 | } 57 | 58 | const token = jwt.sign({ email: user.email }, Authentication.APP_SECRET) 59 | 60 | return { 61 | token, 62 | user 63 | } 64 | } 65 | 66 | function vote (parent, { questionId, sessionId }, context) { 67 | const postedBy = Authentication.getEmail(context) 68 | return VoteRepository.createVote(context.repo, questionId, sessionId, postedBy) 69 | } 70 | 71 | module.exports = { 72 | createSession, 73 | updateSession, 74 | 75 | createQuestion, 76 | 77 | signup, 78 | login, 79 | 80 | vote 81 | } 82 | -------------------------------------------------------------------------------- /app/src/components/QuestionItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { AUTH_TOKEN } from '../constants' 3 | import { Mutation } from 'react-apollo' 4 | import gql from 'graphql-tag' 5 | import { SESSION_QUERY } from './Session' 6 | import { SESSION_LIST_QUERY } from './SessionList' 7 | 8 | const VOTE_MUTATION = gql` 9 | mutation VoteMutation($questionId: ID!, $sessionId: ID!) { 10 | vote(questionId: $questionId, sessionId: $sessionId) { 11 | id 12 | } 13 | } 14 | ` 15 | 16 | class QuestionItem extends Component { 17 | render () { 18 | const authToken = localStorage.getItem(AUTH_TOKEN) 19 | const questionId = this.props.item.id 20 | const sessionId = this.props.sessionId 21 | 22 | return ( 23 |
24 |
25 | {this.props.index + 1}. 26 | {authToken && ( 27 | { 31 | this._updateQuestionData(store, sessionId, questionId) 32 | this._updateSessionListData(store, sessionId) 33 | }} 34 | onError={() => {}} 35 | > 36 | {voteMutation => ( 37 |
38 | ▲ 39 |
40 | )} 41 |
42 | )} 43 |
44 |
45 |
46 | {this.props.item.text} 47 |
48 |
49 | {this.props.item.voteCount} votes | by hede hüde | x time ago 50 |
51 |
52 |
53 | ) 54 | } 55 | 56 | _updateQuestionData (store, sessionId, questionId) { 57 | const queryData = { query: SESSION_QUERY, variables: { id: sessionId } } 58 | const data = store.readQuery(queryData) 59 | const question = data.session.questions.find(q => q.id === questionId) 60 | if (question) { 61 | question.voteCount += 1 62 | 63 | store.writeQuery({ 64 | query: SESSION_QUERY, 65 | variables: { id: sessionId }, 66 | data 67 | }) 68 | } 69 | 70 | this.props.updateQuestions(data.session.questions) 71 | } 72 | 73 | _updateSessionListData (store, sessionId) { 74 | const queryData = { query: SESSION_LIST_QUERY } 75 | const data = store.readQuery(queryData) 76 | const session = data.user.sessions.find(s => s.id === sessionId) 77 | if (session) { 78 | session.totalVoteCount += 1 79 | 80 | store.writeQuery({ 81 | query: SESSION_LIST_QUERY, 82 | data 83 | }) 84 | } 85 | } 86 | } 87 | 88 | export default QuestionItem 89 | -------------------------------------------------------------------------------- /api/src/__test__/resolver/Mutation.test.js: -------------------------------------------------------------------------------- 1 | const Mutation = require('../../resolver/Mutation') 2 | const SessionRepository = require('../../repository/SessionRepository') 3 | const QuestionRepository = require('../../repository/QuestionRepository') 4 | const VoteRepository = require('../../repository/VoteRepository') 5 | const Authentication = require('../../authentication') 6 | 7 | describe('Mutation type resolver', () => { 8 | it('Should create session when logged in', async () => { 9 | const session = { 10 | title: 'a', 11 | description: 'b' 12 | } 13 | SessionRepository.createSession = jest.fn().mockImplementationOnce(() => session) 14 | Authentication.getEmail = jest.fn().mockImplementationOnce(() => 'user@google.com') 15 | 16 | expect(Mutation.createSession({}, { title: 'a', describe: 'b' }, {})).toEqual(session) 17 | }) 18 | 19 | it('Should fail to create session when not logged in', async () => { 20 | expect(Mutation.createSession({}, { title: 'a', describe: 'b' }, {})).toEqual(undefined) 21 | }) 22 | 23 | it('Should update session when logged in', async () => { 24 | const session = { 25 | id: 'session-0', 26 | title: 'a', 27 | description: 'b' 28 | } 29 | SessionRepository.updateSession = jest.fn().mockImplementationOnce(() => session) 30 | Authentication.getUserId = jest.fn().mockImplementationOnce(() => 'user@google.com') 31 | 32 | expect(Mutation.updateSession({}, { id:'session-0', title: 'a', describe: 'b' }, {})).toEqual(session) 33 | }) 34 | 35 | it('Should fail to update session when not logged in', async () => { 36 | expect(Mutation.createSession({}, { id:'session-0', title: 'a', describe: 'b' }, {})).toEqual(undefined) 37 | }) 38 | 39 | it('Should create question when logged in', async () => { 40 | const question = { 41 | id: 'question-0', 42 | text: 'Where is my mind?' 43 | } 44 | QuestionRepository.createQuestion = jest.fn().mockImplementationOnce(() => question) 45 | Authentication.getUserId = jest.fn().mockImplementationOnce(() => 'user-0') 46 | 47 | expect(Mutation.createQuestion({}, { text: 'Where is my mind?' }, {})).toEqual(question) 48 | }) 49 | 50 | it('Should fail to create question when not logged in', async () => { 51 | expect(Mutation.createQuestion({}, { text: 'Where is my mind?' }, {})).toEqual(undefined) 52 | }) 53 | 54 | it('Should create vote when logged in', async () => { 55 | const vote = { 56 | userId: 'user-0', 57 | questionId: 'question-0', 58 | sessionId: 'session-0' 59 | } 60 | VoteRepository.createVote = jest.fn().mockImplementationOnce(() => vote) 61 | Authentication.getUserId = jest.fn().mockImplementationOnce(() => 'user-0') 62 | 63 | expect(Mutation.vote({}, { questionId: 'question-0', sessionId: 'session-0' }, {})).toEqual(vote) 64 | }) 65 | 66 | it('Should fail to create vote when not logged in', async () => { 67 | expect(Mutation.vote({}, { questionId: 'question-0', sessionId: 'session-0' }, {})).toEqual(undefined) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /api/src/data/dynamodb/InitTables.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | AWS.config.update({ 4 | region: "local", 5 | endpoint: "http://localhost:8000" 6 | }) 7 | 8 | const dynamodb = new AWS.DynamoDB() 9 | 10 | const tables = [{ 11 | TableName : "User", 12 | KeySchema: [ 13 | { AttributeName: "email", KeyType: "HASH" } 14 | ], 15 | AttributeDefinitions: [ 16 | { AttributeName: "email", AttributeType: "S" } 17 | ], 18 | ProvisionedThroughput: { 19 | ReadCapacityUnits: 1, 20 | WriteCapacityUnits: 1 21 | } 22 | }, { 23 | TableName : "Session", 24 | KeySchema: [ 25 | { AttributeName: "id", KeyType: "HASH" } 26 | ], 27 | AttributeDefinitions: [ 28 | { AttributeName: "id", AttributeType: "S" }, 29 | { AttributeName: "email", AttributeType: "S" } 30 | ], 31 | ProvisionedThroughput: { 32 | ReadCapacityUnits: 1, 33 | WriteCapacityUnits: 1 34 | }, 35 | GlobalSecondaryIndexes: [{ 36 | IndexName: "EmailIndex", 37 | KeySchema: [{ 38 | AttributeName: "email", 39 | KeyType: "HASH" 40 | }], 41 | Projection: { 42 | ProjectionType: "ALL" 43 | }, 44 | ProvisionedThroughput: { 45 | ReadCapacityUnits: 1, 46 | WriteCapacityUnits: 1 47 | } 48 | }] 49 | }, { 50 | TableName : "Question", 51 | KeySchema: [ 52 | { AttributeName: "id", KeyType: "HASH" }, 53 | ], 54 | AttributeDefinitions: [ 55 | { AttributeName: "id", AttributeType: "S" }, 56 | { AttributeName: "sessionId", AttributeType: "S" } 57 | ], 58 | ProvisionedThroughput: { 59 | ReadCapacityUnits: 1, 60 | WriteCapacityUnits: 1 61 | }, 62 | GlobalSecondaryIndexes: [{ 63 | IndexName: "SessionIdIndex", 64 | KeySchema: [{ 65 | AttributeName: "sessionId", 66 | KeyType: "HASH" 67 | }], 68 | Projection: { 69 | ProjectionType: "ALL" 70 | }, 71 | ProvisionedThroughput: { 72 | ReadCapacityUnits: 1, 73 | WriteCapacityUnits: 1 74 | } 75 | }] 76 | }, { 77 | TableName : "Vote", 78 | KeySchema: [ 79 | { AttributeName: "questionIdEmail", KeyType: "HASH" } 80 | ], 81 | AttributeDefinitions: [ 82 | { AttributeName: "questionIdEmail", AttributeType: "S" }, 83 | { AttributeName: "sessionIdEmail", AttributeType: "S" } 84 | ], 85 | ProvisionedThroughput: { 86 | ReadCapacityUnits: 1, 87 | WriteCapacityUnits: 1 88 | }, 89 | GlobalSecondaryIndexes: [{ 90 | IndexName: "SessionIdEmailIndex", 91 | KeySchema: [{ 92 | AttributeName: "sessionIdEmail", 93 | KeyType: "HASH" 94 | }], 95 | Projection: { 96 | ProjectionType: "ALL" 97 | }, 98 | ProvisionedThroughput: { 99 | ReadCapacityUnits: 1, 100 | WriteCapacityUnits: 1 101 | } 102 | }] 103 | }] 104 | 105 | tables.forEach(table => { 106 | dynamodb.createTable(table, function(err, data) { 107 | if (err) { 108 | console.error("Unable to create table. Error JSON:", JSON.stringify(err, null, 2)); 109 | } else { 110 | console.log("Created table. Table description JSON:", JSON.stringify(data, null, 2)); 111 | } 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /app/src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { AUTH_TOKEN, EMAIL } from '../constants' 3 | import { Mutation } from 'react-apollo' 4 | import gql from 'graphql-tag' 5 | 6 | const SIGNUP_MUTATION = gql` 7 | mutation SignupMutation($email: String!, $password: String!, $name: String!) { 8 | signup(email: $email, password: $password, name: $name) { 9 | token 10 | } 11 | } 12 | ` 13 | 14 | const LOGIN_MUTATION = gql` 15 | mutation LoginMutation($email: String!, $password: String!) { 16 | login(email: $email, password: $password) { 17 | token 18 | } 19 | } 20 | ` 21 | 22 | class Login extends Component { 23 | state = { 24 | login: true, // switch between Login and SignUp 25 | email: '', 26 | password: '', 27 | name: '', 28 | error: false 29 | } 30 | 31 | render() { 32 | const { login, email, password, name, error } = this.state 33 | 34 | function loginDisabledCondition () { 35 | return login ? 36 | (email === '' || password === '') : 37 | (email === '' || password === '' || name === '') 38 | } 39 | 40 | return ( 41 |
42 |

{login ? 'Login' : 'Sign Up'}

43 |
44 | {!login && ( 45 | this.setState({ name: e.target.value })} 49 | type="text" 50 | placeholder="Your name" 51 | /> 52 | )} 53 | this.setState({ email: e.target.value })} 57 | type="text" 58 | placeholder="Your email address" 59 | /> 60 | this.setState({ password: e.target.value })} 64 | type="password" 65 | placeholder="Choose a safe password" 66 | /> 67 | {error &&

Invalid username or password

} 68 |
69 |
70 | this._confirm(data)} 74 | onError={() => this.setState({ error: true })} 75 | > 76 | {(mutation) => ( 77 | 83 | )} 84 | 85 |
this.setState({ login: !login })} 88 | > 89 | {login 90 | ? 'need to create an account?' 91 | : 'already have an account?'} 92 |
93 |
94 |
95 | ) 96 | } 97 | 98 | _confirm = async data => { 99 | const { token } = this.state.login ? data.login : data.signup 100 | this._saveUserData(token) 101 | this.props.history.push(`/`) 102 | } 103 | 104 | _saveUserData = token => { 105 | localStorage.setItem(AUTH_TOKEN, token) 106 | localStorage.setItem(EMAIL, this.state.email) 107 | } 108 | } 109 | 110 | export default Login -------------------------------------------------------------------------------- /api/src/serverless.yml: -------------------------------------------------------------------------------- 1 | service: kuestion-graphql-api 2 | 3 | plugins: 4 | - serverless-offline 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs12.x 9 | region: eu-central-1 10 | memorySize: 128 11 | timeout: 30 12 | 13 | environment: 14 | REGION: ${self:custom.dynamodb.region} 15 | DYNAMODB_ENDPOINT: ${self:custom.dynamodb.url} 16 | 17 | iamRoleStatements: 18 | - Effect: Allow 19 | Action: 20 | - "dynamodb:DescribeTable" 21 | - "dynamodb:Query" 22 | - "dynamodb:Scan" 23 | - "dynamodb:GetItem" 24 | - "dynamodb:PutItem" 25 | - "dynamodb:UpdateItem" 26 | - "dynamodb:DeleteItem" 27 | Resource: 28 | - Fn::GetAtt: [ UserTable, Arn ] 29 | - Fn::GetAtt: [ SessionTable, Arn ] 30 | - Fn::GetAtt: [ QuestionTable, Arn ] 31 | - Fn::GetAtt: [ VoteTable, Arn ] 32 | - Effect: "Allow" 33 | Action: 34 | - "logs:*" 35 | - "cloudwatch:*" 36 | - "xray:*" 37 | Resource: 38 | - "*" 39 | 40 | package: 41 | exclude: 42 | - __test__/** 43 | - data/** 44 | - local.js 45 | - .serverless 46 | 47 | layers: 48 | commonLibs: 49 | path: ../layer 50 | compatibleRuntimes: 51 | - nodejs12.x 52 | 53 | functions: 54 | graphql: 55 | handler: handler.server 56 | layers: 57 | - {Ref: CommonLibsLambdaLayer} 58 | events: 59 | - http: 60 | path: /api 61 | method: post 62 | cors: true 63 | 64 | custom: 65 | dynamodb: 66 | url: 'http://localhost:8000' 67 | region: 'local' 68 | serverless-offline: 69 | httpPort: 4000 70 | 71 | resources: 72 | Resources: 73 | UserTable: 74 | Type: AWS::DynamoDB::Table 75 | Properties: 76 | TableName: User 77 | AttributeDefinitions: 78 | - AttributeName: email 79 | AttributeType: S 80 | KeySchema: 81 | - AttributeName: email 82 | KeyType: HASH 83 | ProvisionedThroughput: 84 | ReadCapacityUnits: 1 85 | WriteCapacityUnits: 1 86 | SessionTable: 87 | Type: AWS::DynamoDB::Table 88 | Properties: 89 | TableName: Session 90 | AttributeDefinitions: 91 | - AttributeName: id 92 | AttributeType: S 93 | - AttributeName: email 94 | AttributeType: S 95 | KeySchema: 96 | - AttributeName: id 97 | KeyType: HASH 98 | ProvisionedThroughput: 99 | ReadCapacityUnits: 1 100 | WriteCapacityUnits: 1 101 | GlobalSecondaryIndexes: 102 | - IndexName: emailIndex 103 | KeySchema: 104 | - AttributeName: email 105 | KeyType: HASH 106 | Projection: 107 | ProjectionType: ALL 108 | ProvisionedThroughput: 109 | ReadCapacityUnits: 1 110 | WriteCapacityUnits: 1 111 | QuestionTable: 112 | Type: AWS::DynamoDB::Table 113 | Properties: 114 | TableName: Question 115 | AttributeDefinitions: 116 | - AttributeName: id 117 | AttributeType: S 118 | - AttributeName: sessionId 119 | AttributeType: S 120 | KeySchema: 121 | - AttributeName: id 122 | KeyType: HASH 123 | ProvisionedThroughput: 124 | ReadCapacityUnits: 1 125 | WriteCapacityUnits: 1 126 | GlobalSecondaryIndexes: 127 | - IndexName: SessionIdIndex 128 | KeySchema: 129 | - AttributeName: sessionId 130 | KeyType: HASH 131 | Projection: 132 | ProjectionType: ALL 133 | ProvisionedThroughput: 134 | ReadCapacityUnits: 1 135 | WriteCapacityUnits: 1 136 | VoteTable: 137 | Type: AWS::DynamoDB::Table 138 | Properties: 139 | TableName: Vote 140 | AttributeDefinitions: 141 | - AttributeName: questionIdEmail 142 | AttributeType: S 143 | - AttributeName: sessionIdEmail 144 | AttributeType: S 145 | KeySchema: 146 | - AttributeName: questionIdEmail 147 | KeyType: HASH 148 | ProvisionedThroughput: 149 | ReadCapacityUnits: 1 150 | WriteCapacityUnits: 1 151 | GlobalSecondaryIndexes: 152 | - IndexName: SessionIdEmailIndex 153 | KeySchema: 154 | - AttributeName: sessionIdEmail 155 | KeyType: HASH 156 | Projection: 157 | ProjectionType: ALL 158 | ProvisionedThroughput: 159 | ReadCapacityUnits: 1 160 | WriteCapacityUnits: 1 -------------------------------------------------------------------------------- /app/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | --------------------------------------------------------------------------------