├── db ├── seeders │ ├── .keep │ ├── 20210503211523-Users.js │ ├── 20210504175817-Questions.js │ └── 20210504175824-Answers.js ├── migrations │ ├── .keep │ ├── 20210504174046-create-question.js │ ├── 20210503201927-create-user.js │ ├── 20210504174717-create-answer.js │ ├── 20210504175437-create-answer-vote.js │ └── 20210504175144-create-question-vote.js └── models │ ├── user.js │ ├── question.js │ ├── answer.js │ ├── answervote.js │ ├── questionvote.js │ └── index.js ├── public ├── stylesheets │ ├── answer-form.css │ ├── index.css │ ├── reset.css │ ├── question-form.css │ ├── about.css │ ├── home-page.css │ ├── login-signup.css │ ├── layout.css │ └── question.css ├── favicon.ico ├── kettle.png └── javascripts │ ├── answer-delete.js │ ├── home-action.js │ ├── answer-edit.js │ └── voting-action.js ├── .DS_Store ├── views ├── error.pug ├── question-form.pug ├── mixins │ └── vote.pug ├── login.pug ├── layout.pug ├── about.pug ├── signup.pug ├── home.pug └── question.pug ├── planning ├── Screen Shot 2021-05-07 at 1.54.42 PM.png ├── Screen Shot 2021-05-07 at 1.55.23 PM.png ├── Screen Shot 2021-05-07 at 1.56.25 PM.png ├── Screen Shot 2021-05-07 at 1.57.01 PM.png ├── Screen Shot 2021-05-07 at 1.57.12 PM.png ├── Screen Shot 2021-05-07 at 1.57.33 PM.png ├── layout-plan.md ├── design.md ├── auth-pseudocode.md └── project-wiki.md ├── .env.example ├── bin └── www ├── .sequelizerc ├── routes ├── utils.js ├── home.js ├── answers.js ├── users.js └── questions.js ├── config ├── index.js └── database.js ├── package.json ├── auth.js ├── app.js ├── .gitignore └── README.md /db/seeders/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/migrations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/stylesheets/answer-form.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/stylesheets/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: #555555; 3 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/.DS_Store -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/kettle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/public/kettle.png -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.54.42 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.54.42 PM.png -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.55.23 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.55.23 PM.png -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.56.25 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.56.25 PM.png -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.57.01 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.57.01 PM.png -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.57.12 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.57.12 PM.png -------------------------------------------------------------------------------- /planning/Screen Shot 2021-05-07 at 1.57.33 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boothjacobs/KettleOverflow/HEAD/planning/Screen Shot 2021-05-07 at 1.57.33 PM.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | DB_USERNAME=kettle_overflow_app 3 | DB_PASSWORD=<> 4 | DB_DATABASE=kettle_overflow 5 | DB_HOST=localhost 6 | SESSION_SECRET=f1f079b1-68fe-4324-8010-0a5cff63a288 7 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const port = process.env.PORT || '8080'; 4 | 5 | const app = require('../app'); 6 | 7 | app.listen(port, () => console.log(`Listening on port ${port}...`)); 8 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('config', 'database.js'), 5 | 'models-path': path.resolve('db', 'models'), 6 | 'seeders-path': path.resolve('db', 'seeders'), 7 | 'migrations-path': path.resolve('db', 'migrations') 8 | }; 9 | -------------------------------------------------------------------------------- /routes/utils.js: -------------------------------------------------------------------------------- 1 | 2 | const csrf = require('csurf'); 3 | const csrfProtection = csrf({ cookie: true }); 4 | 5 | const asyncHandler = (handler) => (req, res, next) => handler(req, res, next).catch(next); 6 | 7 | module.exports = { 8 | csrfProtection, 9 | asyncHandler, 10 | }; 11 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environment: process.env.NODE_ENV || 'development', 3 | port: process.env.PORT || 8080, 4 | sessionSecret: process.env.SESSION_SECRET, 5 | db: { 6 | username: process.env.DB_USERNAME, 7 | password: process.env.DB_PASSWORD, 8 | database: process.env.DB_DATABASE, 9 | host: process.env.DB_HOST, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /planning/layout-plan.md: -------------------------------------------------------------------------------- 1 | Nav bar is fixed (scrolls with you) 2 | Search bar 3 | Login/logout 4 | Kettle icon 5 | 6 | Each question: div containing text 7 | Answer is directly below (own div) 8 | 9 | Simple sidebar 10 | simple GET /questions link 11 | (not links? tea of the day?) 12 | 13 | Simple footer (maybe with empty links) 14 | 15 | Early bonus for CSS 16 | Main page background? Drew's stock photos 17 | -------------------------------------------------------------------------------- /planning/design.md: -------------------------------------------------------------------------------- 1 | Tea Color Scheme: 2 | 3 | Black: #151618 4 | Dark Green: #556b2f 5 | Light Green: #a9a454 6 | Off-White: #efe4d4 7 | Brown: #b07946 8 | 9 | -- when that brown was too dark for me I used: 10 | #cc8c52 (this is the color of the side bar) 11 | 12 | Logo Color Scheme: 13 | Golden Yellow: #ffb923 14 | Dark Blue: #314ea4 15 | Turquoise: #03989e 16 | Red: #e8400c 17 | Orange: #f4701c 18 | 19 | Add Any Other Design Ideas/etc here! -------------------------------------------------------------------------------- /public/stylesheets/reset.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | 10 | * { 11 | box-sizing: border-box; 12 | } 13 | 14 | h1, h2, h3, h4, h5, h6, a, ol, li, ul, div, span, 15 | body, header, nav { 16 | font-size: 1rem; 17 | color: inherit; 18 | background-color: transparent; 19 | margin: 0; 20 | padding: 0; 21 | outline: 0; 22 | font-weight: normal; 23 | } 24 | 25 | h1 { 26 | font-size: 2rem; 27 | } -------------------------------------------------------------------------------- /public/javascripts/answer-delete.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("DOMContentLoaded", (event) => { 2 | 3 | const deleteButton = document.querySelector(".delete-answer") 4 | 5 | if (deleteButton !== null) { 6 | deleteButton.addEventListener("click", async (event) => { 7 | event.preventDefault() 8 | const answerId = event.target.id 9 | await fetch(`/answers/${answerId}`, { 10 | method: 'DELETE' 11 | }) 12 | 13 | window.location.reload() 14 | }) 15 | } 16 | 17 | }) 18 | -------------------------------------------------------------------------------- /config/database.js: -------------------------------------------------------------------------------- 1 | const { 2 | db: { username, password, database, host }, 3 | } = require('./index'); 4 | 5 | module.exports = { 6 | development: { 7 | username, 8 | password, 9 | database, 10 | host, 11 | dialect: 'postgres', 12 | seederStorage: 'sequelize', 13 | }, 14 | production: { 15 | use_env_variable: 'DATABASE_URL', 16 | dialect: 'postgres', 17 | seederStorage: 'sequelize', 18 | dialectOptions: { 19 | ssl: { 20 | require: true, 21 | rejectUnauthorized: false, 22 | }, 23 | }, 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | const User = sequelize.define('User', { 4 | id: { allowNull: false, 5 | autoIncrement: true, 6 | primaryKey: true, 7 | type: DataTypes.INTEGER }, 8 | username: DataTypes.STRING(20), 9 | email: DataTypes.STRING(50), 10 | hashedPassword: DataTypes.STRING.BINARY 11 | }, {}); 12 | User.associate = function(models) { 13 | // associations can be defined here 14 | User.hasMany(models.Question, { foreignKey: 'userId' }); 15 | }; 16 | return User; 17 | }; 18 | -------------------------------------------------------------------------------- /db/models/question.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | const Question = sequelize.define('Question', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: DataTypes.INTEGER 9 | }, 10 | content: DataTypes.TEXT, 11 | userId: DataTypes.INTEGER 12 | }, {}); 13 | Question.associate = function(models) { 14 | // associations can be defined here 15 | Question.belongsTo(models.User, { foreignKey: 'userId' }); 16 | Question.hasMany(models.Answer, { foreignKey: 'questionId' }) 17 | }; 18 | return Question; 19 | }; -------------------------------------------------------------------------------- /db/models/answer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | const Answer = sequelize.define('Answer', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: DataTypes.INTEGER 9 | }, 10 | content: DataTypes.TEXT, 11 | userId: DataTypes.INTEGER, 12 | questionId: DataTypes.INTEGER 13 | }, {}); 14 | Answer.associate = function(models) { 15 | // associations can be defined here 16 | Answer.belongsTo(models.User, { foreignKey: 'userId' }); 17 | Answer.belongsTo(models.Question, { foreignKey: 'questionId' }); 18 | }; 19 | return Answer; 20 | }; -------------------------------------------------------------------------------- /db/models/answervote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | const AnswerVote = sequelize.define('AnswerVote', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: DataTypes.INTEGER 9 | }, 10 | upVote: DataTypes.BOOLEAN, 11 | userId: DataTypes.INTEGER, 12 | answerId: DataTypes.INTEGER 13 | }, {}); 14 | AnswerVote.associate = function(models) { 15 | // associations can be defined here 16 | AnswerVote.belongsTo(models.User, { foreignKey: 'userId' }); 17 | AnswerVote.belongsTo(models.Answer, { foreignKey: 'answerId' }); 18 | }; 19 | return AnswerVote; 20 | }; -------------------------------------------------------------------------------- /db/models/questionvote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = (sequelize, DataTypes) => { 3 | const QuestionVote = sequelize.define('QuestionVote', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: DataTypes.INTEGER 9 | }, 10 | upVote: DataTypes.BOOLEAN, 11 | userId: DataTypes.INTEGER, 12 | questionId: DataTypes.INTEGER 13 | }, {}); 14 | QuestionVote.associate = function(models) { 15 | // associations can be defined here 16 | QuestionVote.belongsTo(models.User, { foreignKey: 'userId' }); 17 | QuestionVote.belongsTo(models.Question, { foreignKey: 'questionId' }); 18 | }; 19 | return QuestionVote; 20 | }; -------------------------------------------------------------------------------- /views/question-form.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | append head 4 | //- add page specific styles by appending to the head 5 | link(rel="stylesheet" href="/stylesheets/question-form.css") 6 | 7 | block content 8 | body(class="questionForm-body") 9 | if errors 10 | div(class="error-list") 11 | each error in errors 12 | p #{error} 13 | form(class='question-form' action="/questions/form", method="post") 14 | input(type="hidden" name="_csrf" value=csrfToken) 15 | div 16 | textarea(name="content", cols="30", rows="10" placeholder="Write your question here!") 17 | div 18 | button(type="submit" id="submit-question") Submit Question 19 | -------------------------------------------------------------------------------- /db/migrations/20210504174046-create-question.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Questions', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | content: { 12 | allowNull: false, 13 | type: Sequelize.TEXT 14 | }, 15 | userId: { 16 | allowNull: false, 17 | references: { model: 'Users' }, 18 | type: Sequelize.INTEGER 19 | }, 20 | createdAt: { 21 | allowNull: false, 22 | type: Sequelize.DATE 23 | }, 24 | updatedAt: { 25 | allowNull: false, 26 | type: Sequelize.DATE 27 | } 28 | }); 29 | }, 30 | down: (queryInterface, Sequelize) => { 31 | return queryInterface.dropTable('Questions'); 32 | } 33 | }; -------------------------------------------------------------------------------- /public/stylesheets/question-form.css: -------------------------------------------------------------------------------- 1 | .error-list { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | color: red; 7 | padding-top: 1rem; 8 | } 9 | .question-form { 10 | 11 | box-sizing: border-box; 12 | /* padding-top: 5%; */ 13 | height: 90vh; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | 19 | } 20 | 21 | textarea { 22 | height: 50vh; 23 | width: 50vw; 24 | resize: none; 25 | border: solid 5px #A9A454; 26 | outline: none; 27 | font-size: xx-large; 28 | border-radius: 1%; 29 | } 30 | 31 | #submit-question { 32 | background-color: #b07946; 33 | border-right: 0; 34 | border: 1px solid grey; 35 | cursor: pointer; 36 | height: 30px; 37 | font-size: 15px; 38 | } 39 | 40 | #submit-question:hover { 41 | color: #EFE4D4; 42 | background-color: #A9A454; 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /planning/auth-pseudocode.md: -------------------------------------------------------------------------------- 1 | POST-LUNCH COMMIT: creating .env with database details, working on session secret (.env.example is up to date) 2 | 3 | 4 | //already exists: Session table, session middleware (app.use) 5 | 6 | Routes, validation stuff, views? for login 7 | 8 | AUTHORIZATION STEPS 9 | 1. path for /user/signup 10 | --view 11 | --route 12 | User enters information 13 | 14 | express validates 15 | 2. validating user input 16 | --helper functions? to be reused 17 | 18 | 3. user is created in database 19 | 20 | 4. user is logged in and redirected to home 21 | (log out button appears on nav bar?) 22 | 23 | 24 | 25 | 26 | Log in 27 | 28 | LOG IN PAGE 29 | --username 30 | --password 31 | 32 | VALIDATED by express to confirm valid entries 33 | IF VALID, check database for existing user 34 | IF USER, persist user login state to session 35 | checking for a cookie (req.session.auth) 36 | 37 | 38 | 39 | 40 | LOG OUT 41 | delete session cookie 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-project-starter", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "per-env", 7 | "start:development": "nodemon -r dotenv/config ./bin/www", 8 | "start:production": "node ./bin/www" 9 | }, 10 | "dependencies": { 11 | "bcryptjs": "^2.4.3", 12 | "connect-session-sequelize": "^7.0.4", 13 | "cookie-parser": "~1.4.4", 14 | "csurf": "^1.11.0", 15 | "debug": "~2.6.9", 16 | "dotenv": "^8.2.0", 17 | "dotenv-cli": "^4.0.0", 18 | "express": "~4.16.1", 19 | "express-session": "^1.17.1", 20 | "express-validator": "^6.10.1", 21 | "http-errors": "~1.6.3", 22 | "morgan": "~1.9.1", 23 | "nodemon": "^2.0.6", 24 | "per-env": "^1.0.2", 25 | "pg": "^8.4.2", 26 | "pug": "2.0.4", 27 | "sequelize": "^5.22.3", 28 | "sequelize-cli": "^5.5.1" 29 | }, 30 | "devDependencies": { 31 | "dotenv": "^8.2.0", 32 | "dotenv-cli": "^4.0.0", 33 | "nodemon": "^2.0.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /db/migrations/20210503201927-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Users', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | username: { 12 | allowNull: false, 13 | type: Sequelize.STRING(20) 14 | }, 15 | email: { 16 | allowNull: false, 17 | unique: true, 18 | type: Sequelize.STRING(50) 19 | }, 20 | hashedPassword: { 21 | allowNull: false, 22 | type: Sequelize.STRING.BINARY 23 | }, 24 | createdAt: { 25 | allowNull: false, 26 | type: Sequelize.DATE 27 | }, 28 | updatedAt: { 29 | allowNull: false, 30 | type: Sequelize.DATE 31 | } 32 | }); 33 | }, 34 | down: (queryInterface, Sequelize) => { 35 | return queryInterface.dropTable('Users'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /views/mixins/vote.pug: -------------------------------------------------------------------------------- 1 | 2 | mixin qUpvote(questionId) 3 | div(class="vote-div") 4 | if locals.user 5 | div(id=questionId class="question-up-vote") 🔼 6 | else 7 | div + 8 | div(class="qUpVoteTally") #{upvotes} 9 | 10 | mixin qDownvote(questionId) 11 | div(class="vote-div") 12 | div(class="qDownVoteTally") #{downvotes} 13 | if locals.user 14 | div(id=questionId class="question-down-vote") 🔽 15 | else 16 | div - 17 | 18 | mixin aUpvote(answerId) 19 | div(class="vote-div") 20 | if locals.user 21 | div(id=answerId class="answer-up-vote") 🔼 22 | else 23 | div + 24 | div(class=`tally${answerId}`) #{answerVotes[answerId].length} 25 | 26 | mixin aDownvote(answerId) 27 | div(class="vote-div") 28 | div(class=`tallyDown${answerId}`) #{answerVotes[answerId].length} 29 | if locals.user 30 | div(id=answerId class="answer-down-vote") 🔽 31 | else 32 | div - 33 | -------------------------------------------------------------------------------- /db/migrations/20210504174717-create-answer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Answers', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | content: { 12 | allowNull: false, 13 | type: Sequelize.TEXT 14 | }, 15 | userId: { 16 | allowNull: false, 17 | references: { model: 'Users' }, 18 | type: Sequelize.INTEGER 19 | }, 20 | questionId: { 21 | allowNull: false, 22 | references: { model: 'Questions' }, 23 | type: Sequelize.INTEGER 24 | }, 25 | createdAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE 28 | }, 29 | updatedAt: { 30 | allowNull: false, 31 | type: Sequelize.DATE 32 | } 33 | }); 34 | }, 35 | down: (queryInterface, Sequelize) => { 36 | return queryInterface.dropTable('Answers'); 37 | } 38 | }; -------------------------------------------------------------------------------- /db/migrations/20210504175437-create-answer-vote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('AnswerVotes', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | upVote: { 12 | allowNull: false, 13 | type: Sequelize.BOOLEAN 14 | }, 15 | userId: { 16 | allowNull: false, 17 | references: { model: 'Users' }, 18 | type: Sequelize.INTEGER 19 | }, 20 | answerId: { 21 | allowNull: false, 22 | references: { model: 'Answers' }, 23 | type: Sequelize.INTEGER 24 | }, 25 | createdAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE 28 | }, 29 | updatedAt: { 30 | allowNull: false, 31 | type: Sequelize.DATE 32 | } 33 | }); 34 | }, 35 | down: (queryInterface, Sequelize) => { 36 | return queryInterface.dropTable('AnswerVotes'); 37 | } 38 | }; -------------------------------------------------------------------------------- /db/migrations/20210504175144-create-question-vote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('QuestionVotes', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | upVote: { 12 | allowNull: false, 13 | type: Sequelize.BOOLEAN 14 | }, 15 | userId: { 16 | allowNull: false, 17 | references: { model: 'Users' }, 18 | type: Sequelize.INTEGER 19 | }, 20 | questionId: { 21 | allowNull: false, 22 | references: { model: 'Questions' }, 23 | type: Sequelize.INTEGER 24 | }, 25 | createdAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE 28 | }, 29 | updatedAt: { 30 | allowNull: false, 31 | type: Sequelize.DATE 32 | } 33 | }); 34 | }, 35 | down: (queryInterface, Sequelize) => { 36 | return queryInterface.dropTable('QuestionVotes'); 37 | } 38 | }; -------------------------------------------------------------------------------- /routes/home.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { csrfProtection, asyncHandler } = require('./utils'); 4 | const { Question, User, Answer, sequelize, Sequelize } = require('../db/models') 5 | 6 | /* GET home page. */ 7 | // router.get('/', function(req, res, next) { 8 | 9 | // res.render('home', { title: 'Kettle Overflow' }); 10 | // }); 11 | 12 | router.get('/', csrfProtection, asyncHandler(async (req, res, next) => { 13 | const questions = await Question.findAll({ 14 | include: [ User ], 15 | order: [['createdAt', 'DESC']], 16 | limit: 10, 17 | }); 18 | const number = Math.ceil(Math.random() * 5).toString() 19 | // console.log(number, '-----------------------------') 20 | res.render('home', { 21 | title: 'Kettle Overflow', 22 | csrfToken: req.csrfToken(), 23 | questions, 24 | number 25 | }) 26 | })); 27 | 28 | router.get('/about', asyncHandler(async (req, res) => { 29 | res.render('about', { 30 | title: 'About' 31 | }) 32 | })) 33 | 34 | module.exports = router; 35 | -------------------------------------------------------------------------------- /db/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Sequelize = require('sequelize'); 6 | const basename = path.basename(__filename); 7 | const env = process.env.NODE_ENV || 'development'; 8 | const config = require(__dirname + '/../../config/database.js')[env]; 9 | const db = {}; 10 | 11 | let sequelize; 12 | if (config.use_env_variable) { 13 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 14 | } else { 15 | sequelize = new Sequelize(config.database, config.username, config.password, config); 16 | } 17 | 18 | fs 19 | .readdirSync(__dirname) 20 | .filter(file => { 21 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); 22 | }) 23 | .forEach(file => { 24 | const model = sequelize['import'](path.join(__dirname, file)); 25 | db[model.name] = model; 26 | }); 27 | 28 | Object.keys(db).forEach(modelName => { 29 | if (db[modelName].associate) { 30 | db[modelName].associate(db); 31 | } 32 | }); 33 | 34 | db.sequelize = sequelize; 35 | db.Sequelize = Sequelize; 36 | 37 | module.exports = db; 38 | -------------------------------------------------------------------------------- /auth.js: -------------------------------------------------------------------------------- 1 | //require models 2 | const db = require('./db/models'); 3 | 4 | const loginUser = (req, res, user) => { 5 | req.session.auth = { 6 | userId: user.id, 7 | }; 8 | }; 9 | const logoutUser = (req, res) => { 10 | delete req.session.auth; 11 | }; 12 | 13 | const requireAuth = (req, res, next) => { 14 | if (!res.locals.authenticated) { 15 | return res.redirect('/users/login'); 16 | } 17 | return next(); 18 | }; 19 | 20 | const restoreUser = async (req, res, next) => { 21 | 22 | // console.log(req.session); 23 | if (req.session.auth) { 24 | const { userId } = req.session.auth; 25 | try { 26 | const user = await db.User.findByPk(userId); 27 | if (user) { 28 | res.locals.authenticated = true; 29 | res.locals.user = user; 30 | next(); 31 | } 32 | } catch (err) { 33 | res.locals.authenticated = false; 34 | next(err); 35 | } 36 | } else { 37 | res.locals.authenticated = false; 38 | next(); 39 | } 40 | }; 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | //export authentication functions 49 | 50 | // 51 | module.exports = { loginUser, logoutUser, restoreUser, requireAuth } 52 | -------------------------------------------------------------------------------- /public/stylesheets/about.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #EFE4D4; 3 | min-height: 400px; 4 | margin-bottom: 100px; 5 | clear: both; 6 | } 7 | 8 | li { 9 | list-style-type: none; 10 | } 11 | 12 | .container { 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | 18 | } 19 | 20 | .info { 21 | padding: 1%; 22 | height: 10%; 23 | width: 40%; 24 | background-color: white; 25 | border: solid 1px #A9A454; 26 | margin: 1%; 27 | text-align: center; 28 | } 29 | 30 | .developer-info { 31 | display: flex; 32 | flex-direction: column; 33 | justify-content: center; 34 | align-items: center; 35 | display: flex; 36 | flex-direction: column; 37 | padding: 1%; 38 | height: 10%; 39 | width: 40%; 40 | background-color: white; 41 | border: solid 1px #A9A454; 42 | margin: 1%; 43 | text-align: center; 44 | } 45 | 46 | #sarah, 47 | #drew, #lauren { 48 | margin: 10px; 49 | } 50 | 51 | #sarah-name, 52 | #drew-name, 53 | #lauren-name { 54 | color: #556b2f; 55 | } 56 | 57 | #sarah-list, 58 | #drew-list, 59 | #lauren-list { 60 | color: black; 61 | } -------------------------------------------------------------------------------- /views/login.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | append head 4 | //- add page specific styles by appending to the head 5 | link(rel="stylesheet" href="/stylesheets/login-signup.css") 6 | 7 | block content 8 | if errors 9 | div(class="error-list") 10 | each error in errors 11 | p #{error} 12 | div(class="grid-container") 13 | div: img(src="/kettle.png" class="login-header") 14 | div(class="form-box") 15 | form(action="/users/login" method="post" id="login-form") 16 | input(type="hidden" name="_csrf" value=csrfToken) 17 | div(class="username-entry") 18 | div: label(for="username") Username 19 | div: input(type="text" name="username" size=30) 20 | div(class="password-entry") 21 | div: label(for="password") Password 22 | div: input(type="password" name="password" size=30) 23 | div(class="buttons-div") 24 | div: button(type="submit" form="login-form" id="main-login-button") Log in 25 | form(action="/users/demo" method="post" id="demo-form") 26 | div: button(type="submit" class="demo-user-button") Demo User 27 | div(class="switch-link"): a(href="/users/signup") Need to sign up? 28 | -------------------------------------------------------------------------------- /db/seeders/20210503211523-Users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | /* 6 | Add altering commands here. 7 | Return a promise to correctly handle asynchronicity. 8 | 9 | Example: 10 | */ 11 | return queryInterface.bulkInsert('Users', [ 12 | { 13 | username: "TeaLover", 14 | email: "lovesTea@email.com", 15 | hashedPassword: "$2a$10$ucpzJCTuTRfMnA45xaES7eGtdyMgMHbtl0OSYlxxwQII4dpGC2yu6", 16 | createdAt: new Date(), 17 | updatedAt: new Date() 18 | }, 19 | { 20 | username: "ElectricKettle", 21 | email: "brewer@email.com", 22 | hashedPassword: "$2a$10$l1//vRtY03yY8DRx0HOOnOQandgZTphhkJXeSNoE0RrhjERRtvbIm", 23 | createdAt: new Date(), 24 | updatedAt: new Date() 25 | }, 26 | { 27 | username: "oolong", 28 | email: "oolong@email.com", 29 | hashedPassword: "$2a$10$QcdAi9eJO6ct5WHfBgsUwuw0Qd9FRJyt3SRaWsOAteIXoj3fQbGfK", 30 | createdAt: new Date(), 31 | updatedAt: new Date() 32 | }, 33 | ], {}); 34 | }, 35 | 36 | down: (queryInterface, Sequelize) => { 37 | /* 38 | Add reverting commands here. 39 | Return a promise to correctly handle asynchronicity. 40 | 41 | Example: 42 | */ 43 | return queryInterface.bulkDelete('Users', null, {}); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | block head 5 | title= title 6 | link(rel='stylesheet' href='/stylesheets/reset.css') 7 | link(rel="stylesheet" href="/stylesheets/layout.css") 8 | 9 | div 10 | nav(class="navbar") 11 | div(class="logo-section" ) 12 | a(href="/") 13 | img(src="/kettle.png" id="kettle-image") 14 | a(href="/" id="kettle-overflow") Kettle Overflow 15 | div(class="search") 16 | form(action="/questions" method="post") 17 | input(type="text" placeholder="Search..." id="search-bar" name="content") 18 | button(type="submit" id="submit-search") Submit 19 | div 20 | if locals.authenticated 21 | form(action="/users/logout" method="post") 22 | button(type="submit" id="logout-button") Log Out 23 | //- a(href="/users/login" id="logout-link") Log Out 24 | else 25 | div(id="login-button") 26 | a(href="/users/login" id="login-link") Log In 27 | div(id="signup-button") 28 | a(href="/users/signup" id="logout-link") Sign Up 29 | div 30 | div(class="footer") 31 | div(class="made-by") 32 | h3 Brewed Up By #[a( href='https://github.com/boothjacobs' ) Sarah], #[a( href='https://github.com/drewlong314' ) Drew], & #[a( href='https://github.com/laurengus17' ) Lauren] 33 | 34 | block content 35 | -------------------------------------------------------------------------------- /views/about.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | append head 4 | link(rel="stylesheet" href="/stylesheets/about.css") 5 | 6 | block content 7 | div(class="container") 8 | p(class='info') Kettle Overflow encourages tea enthusiasts everywhere to ask and answer tea related questions. 9 | div(class="developer-info") 10 | h1 Meet the Developers 11 | 12 | div(class="developer" id="sarah") 13 | h1(id="sarah-name") Sarah 14 | ul(id="sarah-list") 15 | li #[a( href='https://github.com/boothjacobs' ) GitHub] 16 | li #[a( href='https://www.linkedin.com/in/sarah-jacobs-53433923/' ) LinkedIn] 17 | div(class="developer" id="drew") 18 | h1(id="drew-name") Drew 19 | ul(id="drew-list") 20 | li #[a( href='https://github.com/drewlong314' ) GitHub] 21 | li #[a( href='https://www.linkedin.com/in/drew-long-361772172/' ) LinkedIn] 22 | div(class="developer" id="lauren") 23 | h1(id="lauren-name") Lauren 24 | ul(id="lauren-list") 25 | li #[a( href='https://github.com/laurengus17' ) GitHub] 26 | li #[a( href='https://www.linkedin.com/in/lauren-gustafson-7b8877b3/' ) LinkedIn] 27 | p(class='info') Seed data from #[a( href='https://www.harney.com/pages/this-is-tea') https://www.harney.com/pages/this-is-tea] 28 | -------------------------------------------------------------------------------- /db/seeders/20210504175817-Questions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.bulkInsert('Questions', [ 6 | { 7 | content: 'What is the best way to store loose-leaf teas?', 8 | userId: 1, 9 | createdAt: new Date(), 10 | updatedAt: new Date() 11 | }, 12 | { 13 | content: 'Should tea be kept in the freezer?', 14 | userId: 1, 15 | createdAt: new Date(), 16 | updatedAt: new Date() 17 | }, 18 | { 19 | content: 'How much caffeine is in green tea?', 20 | userId: 1, 21 | createdAt: new Date(), 22 | updatedAt: new Date() 23 | }, 24 | { 25 | content: 'What is the difference between herbal tea and decaffeinated tea?', 26 | userId: 1, 27 | createdAt: new Date(), 28 | updatedAt: new Date() 29 | }, 30 | { 31 | content: 'What is an Oolong?', 32 | userId: 1, 33 | createdAt: new Date(), 34 | updatedAt: new Date() 35 | }, 36 | { 37 | content: 'What is green tea and black tea?', 38 | userId: 1, 39 | createdAt: new Date(), 40 | updatedAt: new Date() 41 | }, 42 | ], {}); 43 | }, 44 | 45 | down: (queryInterface, Sequelize) => { 46 | /* 47 | Add reverting commands here. 48 | Return a promise to correctly handle asynchronicity. 49 | 50 | Example: 51 | */ 52 | return queryInterface.bulkDelete('Questions', null, {}); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /views/signup.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | append head 4 | //- add page specific styles by appending to the head 5 | link(rel="stylesheet" href="/stylesheets/login-signup.css") 6 | 7 | block content 8 | if errors 9 | div(class="error-list") 10 | each error in errors 11 | p #{error} 12 | div(class="grid-container") 13 | div: img(src="/kettle.png" class="login-header") 14 | div(class="form-box") 15 | form(method="post" action="/users/signup" id="signup-form") 16 | input(type="hidden" name="_csrf" value=csrfToken) 17 | div(class="username-entry") 18 | label(for="username") Username 19 | input(type="text" name="username" size=30) 20 | div(class="email-entry") 21 | label(for="email") Email 22 | input(type="email" name="email" size=30) 23 | div(class="password-entry") 24 | label(for="password") Password 25 | input(type="password" name="password" size=30) 26 | div(class="confirm-entry") 27 | label(for="confirmPassword") Confirm Password 28 | input(type="password" name="confirmPassword" size=30) 29 | div(class="buttons-div") 30 | div: button(type="submit" form="signup-form" id="main-signup-button") Sign Up 31 | form(action="/users/demo" method="post" id="demo-form") 32 | div: button(type="submit" class="demo-user-button") Demo User 33 | div(class="switch-link"): a(href="/users/login") Already have an account? 34 | -------------------------------------------------------------------------------- /public/stylesheets/home-page.css: -------------------------------------------------------------------------------- 1 | .home-body { 2 | margin-bottom: 100px; 3 | } 4 | 5 | .sidebar { 6 | width: 250px; 7 | padding: 0px; 8 | background-color: #cc8c52; 9 | opacity: 70%; 10 | position: absolute; 11 | height: 70%; 12 | overflow: auto; 13 | } 14 | 15 | .sidebar-button { 16 | border: none; 17 | background-color: #cc8c52; 18 | cursor: pointer; 19 | font-weight: bold; 20 | font-size: 1rem; 21 | } 22 | 23 | #link-1, 24 | #link-2, 25 | #link-3, 26 | #link-4 { 27 | text-align: center; 28 | padding: 30px; 29 | color: black; 30 | } 31 | 32 | .body-content { 33 | margin-left: 250px; 34 | padding: 10px 15px; 35 | /* text-align: center; */ 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: center; 39 | align-items: center; 40 | } 41 | 42 | /* .questions-table { 43 | margin-left: 300px; 44 | margin-right: 50px; 45 | padding: 20px; 46 | width: 700px; 47 | text-align: center; 48 | } */ 49 | 50 | .head-questions-table { 51 | text-align: center; 52 | padding: 40px; 53 | } 54 | 55 | .questions-table-body { 56 | text-align: center; 57 | padding: 40px; 58 | } 59 | 60 | #User-Table, 61 | #Question-Table, 62 | #usernames, 63 | #question-body { 64 | border-bottom: 1px solid #A9A454; 65 | width: 250px; 66 | padding: 15px; 67 | } 68 | 69 | #usernames:hover, 70 | #question-body:hover { 71 | background-color: #556B2F; 72 | color: #EFE4D4; 73 | } 74 | 75 | #to-hover:hover { 76 | background-color: #556B2F; 77 | color: #EFE4D4; 78 | } 79 | 80 | #question-body__anchor { 81 | text-decoration: none; 82 | display: block; 83 | } 84 | -------------------------------------------------------------------------------- /public/javascripts/home-action.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("DOMContentLoaded", (event) => { 2 | const editButton = document.querySelector('.edit') 3 | const questionContent = document.querySelector('.question-content') 4 | const questionFooter = document.querySelector('.question-footer') 5 | const questionBox = document.querySelector('#question-box') 6 | if (editButton !== null) { 7 | editButton.addEventListener('click', async e => { 8 | questionBox.removeChild(questionFooter) 9 | 10 | const form = document.createElement('form') 11 | form.classList.add('edit-form') 12 | questionBox.appendChild(form) 13 | 14 | const textArea = document.createElement('textarea') 15 | textArea.classList.add('edit-text-area') 16 | textArea.innerHTML = questionContent.innerHTML 17 | form.appendChild(textArea) 18 | 19 | const submitButton = document.createElement('input') 20 | submitButton.classList.add('edit-submit') 21 | submitButton.setAttribute('type', 'submit') 22 | submitButton.setAttribute('value', 'Submit Edit') 23 | form.appendChild(submitButton) 24 | 25 | questionBox.removeChild(questionContent) 26 | submitButton.addEventListener('click', async e => { 27 | e.preventDefault() 28 | const content = { content: textArea.value } 29 | 30 | await fetch(window.location.href, { 31 | method: 'PUT', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | }, 35 | body: JSON.stringify(content), 36 | }) 37 | 38 | window.location.reload() 39 | }) 40 | }) 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /public/stylesheets/login-signup.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #EFE4D4; 3 | min-height: 400px; 4 | margin-bottom: 100px; 5 | clear: both; 6 | } 7 | 8 | .error-list { 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | color: red; 14 | padding-top: 1rem; 15 | } 16 | 17 | .grid-container { 18 | width: auto; 19 | margin: auto; 20 | display: grid; 21 | justify-items: center; 22 | align-items: center; 23 | grid-template-columns: 1; 24 | grid-template-rows: 150px 300px 100px; 25 | max-width: 500px; 26 | } 27 | 28 | .login-header { 29 | grid-row: 1; 30 | height: 125px; 31 | width: 125px; 32 | } 33 | 34 | .form-box { 35 | box-sizing: border-box; 36 | height: 300px; 37 | width: 250px; 38 | background-color: white; 39 | grid-row: 2; 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: space-around; 43 | align-items: center; 44 | box-shadow: 0px 0px 15px darkgrey; 45 | border-radius: 10px; 46 | } 47 | 48 | .username-entry { 49 | margin: 10px; 50 | } 51 | 52 | .email-entry { 53 | margin: 10px; 54 | } 55 | 56 | .password-entry { 57 | margin: 10px; 58 | } 59 | 60 | .confirm-entry { 61 | margin: 10px; 62 | } 63 | 64 | #main-login-button, 65 | #main-signup-button 66 | { 67 | margin: 5px; 68 | width: 200px; 69 | } 70 | 71 | .demo-user-button { 72 | margin: 5px; 73 | width: 200px; 74 | } 75 | 76 | .switch-link { 77 | grid-row: 3; 78 | } 79 | 80 | #main-login-button, 81 | .demo-user-button, 82 | #main-signup-button { 83 | background-color: #b07946; 84 | border: 1px solid grey; 85 | cursor: pointer; 86 | height: 25px; 87 | font-size: 15px; 88 | } 89 | 90 | #main-login-button:hover, 91 | .demo-user-button:hover, 92 | #main-signup-button:hover { 93 | color: #EFE4D4; 94 | background-color: #A9A454; 95 | } -------------------------------------------------------------------------------- /views/home.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | append head 4 | meta(property="og:type" content="website") 5 | meta(name="image" property="og:image" content="https://live.staticflickr.com/65535/51335167569_5f9993a763_h.jpg") 6 | meta(name="description" property="og:description" content="KettleOverflow: StackOverflow clone") 7 | link(rel="stylesheet" href="/stylesheets/home-page.css") 8 | //- add page specific js 9 | script(src="/javascripts/home-action.js" type="module" defer) 10 | 11 | block content 12 | body(class="home-body") 13 | div(class="sidebar") 14 | div(id="link-1") 15 | form(action="/users/demo" method="post" id="demo-form") 16 | div: button(type="submit" class="sidebar-button") Demo User 17 | div(id="link-2") 18 | form(action=`/questions/${number}` method="get" id="random-form") 19 | div: button(type="submit" class="sidebar-button") Random Question 20 | div(id="link-3") 21 | form(action="/questions/form" method="get" id="ask-question-form") 22 | div: button(type="submit" class="sidebar-button") Ask A Question 23 | div(id="link-4") 24 | form(action="/about" method="get" id="about-form") 25 | div: button(type="submit" class="sidebar-button") About 26 | div(class="body-content") 27 | h1 Welcome 28 | p Cheers, Have A Cuppa 29 | table(class="questions-table") 30 | thead(class="head-questions-table") 31 | tr 32 | th(id="User-Table") User 33 | th(id="Question-Table") Question 34 | tbody(class="questions-table-body") 35 | each question in questions 36 | tr(id="to-hover") 37 | td(id="usernames") 38 | a(id="question-body__anchor" href=`/questions/${question.id}`)= question.User.username 39 | td(id="question-body") 40 | a(id="question-body__anchor" href=`/questions/${question.id}`)= question.content 41 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors'); 2 | const express = require('express'); 3 | const path = require('path'); 4 | const cookieParser = require('cookie-parser'); 5 | const logger = require('morgan'); 6 | const { sequelize } = require('./db/models'); 7 | const session = require('express-session'); 8 | const SequelizeStore = require('connect-session-sequelize')(session.Store); 9 | const homeRouter = require('./routes/home'); 10 | const usersRouter = require('./routes/users'); 11 | const questionsRouter = require('./routes/questions'); 12 | const answersRouter = require('./routes/answers'); 13 | const { sessionSecret } = require('./config'); 14 | const { restoreUser } = require('./auth'); 15 | 16 | 17 | 18 | const app = express(); 19 | 20 | 21 | // view engine setup 22 | app.set('view engine', 'pug'); 23 | 24 | app.use(logger('dev')); 25 | app.use(express.json()); 26 | app.use(express.urlencoded({ extended: false })); 27 | app.use(cookieParser(sessionSecret)); 28 | app.use(express.static(path.join(__dirname, 'public'))); 29 | 30 | 31 | // set up session middleware 32 | const store = new SequelizeStore({ db: sequelize }); 33 | 34 | app.use( 35 | session({ 36 | secret: sessionSecret, 37 | store, 38 | saveUninitialized: false, 39 | resave: false, 40 | }) 41 | ); 42 | 43 | app.use(restoreUser); 44 | 45 | 46 | // create Session table if it doesn't already exist 47 | store.sync(); 48 | 49 | app.use('/', homeRouter); 50 | app.use('/users', usersRouter); 51 | app.use('/questions', questionsRouter); 52 | app.use('/answers', answersRouter); 53 | 54 | // catch 404 and forward to error handler 55 | app.use(function (req, res, next) { 56 | next(createError(404)); 57 | }); 58 | 59 | // error handler 60 | app.use(function (err, req, res, next) { 61 | // set locals, only providing error in development 62 | res.locals.message = err.message; 63 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 64 | 65 | // render the error page 66 | res.status(err.status || 500); 67 | res.render('error'); 68 | }); 69 | 70 | module.exports = app; 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /public/stylesheets/layout.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #EFE4D4; 3 | } 4 | 5 | .navbar { 6 | background-color: #A9A454; 7 | overflow: hidden; 8 | width: 100%; 9 | top: 0; 10 | } 11 | 12 | .logo-section { 13 | padding-top: 10px; 14 | } 15 | 16 | #kettle-overflow { 17 | text-decoration: none; 18 | float: left; 19 | margin: 20px 0px; 20 | } 21 | 22 | #kettle-image { 23 | float: left; 24 | width: 40px; 25 | height: 40px; 26 | margin: 7px 5px; 27 | } 28 | 29 | #signup-button, 30 | #login-button, 31 | #logout-button { 32 | float: right; 33 | display: block; 34 | color: black; 35 | text-align: center; 36 | padding: 10px; 37 | font-size: 15px; 38 | box-sizing: border-box; 39 | } 40 | 41 | #login-link, 42 | #signup-link, 43 | #logout-link { 44 | text-decoration: none; 45 | } 46 | 47 | #logout-button { 48 | float: right; 49 | display: block; 50 | background-color: #A9A454; 51 | color: black; 52 | border: none; 53 | cursor: pointer; 54 | font-size: 15px; 55 | text-align: center; 56 | padding: 10px; 57 | box-sizing: border-box; 58 | } 59 | 60 | #login-button :hover, 61 | #signup-button :hover, 62 | #logout-button :hover { 63 | background-color: #556B2F; 64 | color: #EFE4D4; 65 | } 66 | 67 | .search { 68 | width: 600px; 69 | } 70 | 71 | #search-bar { 72 | width: 490px; 73 | height: 30px; 74 | float: none; 75 | display: inline-block; 76 | position: absolute; 77 | top: 5%; 78 | left: 55%; 79 | border: 1px solid grey; 80 | font-size: 15px; 81 | transform: translate(-50%, -50%); 82 | } 83 | 84 | #submit-search { 85 | position: absolute; 86 | width: 90px; 87 | height: 30px; 88 | font-size: 15px; 89 | display: none; 90 | cursor: pointer; 91 | text-align: center; 92 | border-right: 0; 93 | border: 1px solid grey; 94 | top: 2.5%; 95 | left: 65%; 96 | background-color: #f4701c; 97 | } 98 | 99 | #submit-search:hover { 100 | color: #EFE4D4; 101 | background-color: #03989e; 102 | } 103 | 104 | .footer { 105 | background-color: #A9A454; 106 | width: 100%; 107 | padding: 10px 0px; 108 | bottom: 0; 109 | overflow: hidden; 110 | position: fixed; 111 | } 112 | 113 | .made-by { 114 | color: black; 115 | float: right; 116 | padding: 10px; 117 | } 118 | -------------------------------------------------------------------------------- /public/javascripts/answer-edit.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("DOMContentLoaded", (event) => { 2 | const editAnswer = document.querySelectorAll('.edit-answer') 3 | const answerContentPug = document.querySelector('.answer-content') 4 | const answerFooter = document.querySelector('.answer-footer') 5 | const answerBox = document.getElementById("answer-box") 6 | 7 | if (editAnswer !== null) { 8 | editAnswer.forEach((button) => { 9 | button.addEventListener('click', async (event) => { 10 | answerBox.removeChild(answerFooter) 11 | 12 | const answerContent = event.path[1].children[0] 13 | const answerId = event.target.id 14 | const answerBoxNew = document.getElementById(answerId) 15 | const form = document.createElement('form') 16 | form.style.display = 'flex' 17 | form.style.flexDirection = 'column' 18 | answerBoxNew.appendChild(form) 19 | 20 | const contentSplit = answerContent.innerHTML.split("<") 21 | const textArea = document.createElement('textarea') 22 | 23 | textArea.innerHTML = contentSplit[0] 24 | textArea.style.resize = 'none' 25 | textArea.style.height = '10%' 26 | textArea.style.fontSize = '1rem' 27 | form.appendChild(textArea) 28 | 29 | const submitEditButton = document.createElement('button') 30 | submitEditButton.style.height = '7%' 31 | submitEditButton.style.width = '30%' 32 | submitEditButton.style.marginTop = '2px' 33 | submitEditButton.style.alignSelf = 'flex-start' 34 | submitEditButton.setAttribute('type', 'submit') 35 | submitEditButton.setAttribute('id', 'submit-edit-button') 36 | submitEditButton.innerHTML = 'Submit Edit' 37 | form.appendChild(submitEditButton) 38 | answerBox.removeChild(answerContentPug) 39 | 40 | submitEditButton.addEventListener('click', async (event) => { 41 | event.preventDefault() 42 | const content = { content: textArea.value } 43 | 44 | await fetch(`/answers/${answerId}`, { 45 | method: 'PUT', 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | }, 49 | body: JSON.stringify(content), 50 | }) 51 | 52 | window.location.reload() 53 | }) 54 | }) 55 | }) 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /routes/answers.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bcrypt = require('bcryptjs'); 3 | const router = express.Router(); 4 | const { requireAuth } = require('../auth') //Need login/ logout 5 | const { check, validationResult } = require('express-validator'); 6 | const { Question, User, Answer, AnswerVote, sequelize, Sequelize } = require('../db/models') 7 | const { csrfProtection, asyncHandler } = require('./utils'); 8 | const { Op } = require("sequelize"); 9 | 10 | async function voteExists(answerId, userId) { 11 | const ifVote = await AnswerVote.findOne({ 12 | where: { 13 | [Op.and]: [ 14 | { answerId }, 15 | { userId }, 16 | ] 17 | } 18 | }); 19 | return ifVote; 20 | }; 21 | async function answerUpvotes(answerId) { 22 | const answerUpvotes = await AnswerVote.findAll({ 23 | where: { 24 | [Op.and]: [ { answerId }, { upVote: true } ] 25 | } 26 | }); 27 | return answerUpvotes; 28 | }; 29 | async function answerDownvotes(answerId) { 30 | const answerDownvotes = await AnswerVote.findAll({ 31 | where: { 32 | [Op.and]: [ { answerId }, { upVote: false } ] 33 | } 34 | }); 35 | return answerDownvotes; 36 | }; 37 | 38 | router.put('/:id(\\d+)', asyncHandler(async (req, res) => { 39 | const answerId = req.params.id 40 | 41 | const answer = await Answer.findByPk(answerId) 42 | 43 | answer.content = req.body.content 44 | await answer.save() 45 | res.sendStatus(201) 46 | })) 47 | 48 | router.delete('/:id(\\d+)', asyncHandler(async (req, res) => { 49 | const answer = await Answer.findOne({ 50 | where: { 51 | id: req.params.id 52 | } 53 | }); 54 | 55 | await answer.destroy() 56 | res.redirect('/') 57 | 58 | })); 59 | 60 | router.post('/:id/votes', requireAuth, asyncHandler(async (req, res) => { 61 | const answerId = req.params.id; 62 | const { userId } = req.session.auth; 63 | const { vote } = req.body; 64 | 65 | const alreadyVote = await voteExists(answerId, userId); 66 | if (alreadyVote) { 67 | await alreadyVote.destroy(); 68 | } else { 69 | await AnswerVote.create({ 70 | upVote: vote, 71 | userId, 72 | answerId 73 | }); 74 | } 75 | 76 | let upvotesA = await answerUpvotes(answerId); 77 | let downvotesA = await answerDownvotes(answerId); 78 | 79 | res.setHeader('Content-Type', 'application/json'); 80 | res.send({upvotes: upvotesA, downvotes: downvotesA}); 81 | })); 82 | 83 | module.exports = router; 84 | -------------------------------------------------------------------------------- /views/question.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | include mixins/vote.pug 3 | 4 | append head 5 | //- add page specific styles by appending to the head 6 | link(rel="stylesheet" href="/stylesheets/question.css") 7 | //- add page specific js 8 | script(src="/javascripts/home-action.js" type="module" defer) 9 | script(src="/javascripts/voting-action.js" type="module" defer) 10 | script(src="/javascripts/answer-edit.js" type="module" defer) 11 | script(src="/javascripts/answer-delete.js" type="module" defer) 12 | 13 | block content 14 | body(class="question-body") 15 | div(id='page-container') 16 | if question 17 | div(id='question-box') 18 | p(class='question-content')= question.content 19 | div(class='question-footer') 20 | div(class='votes') 21 | +qUpvote(question.id) 22 | +qDownvote(question.id) 23 | if locals.user 24 | if locals.user.id === question.userId 25 | button(class='edit') Edit 26 | else 27 | div 28 | p(class="created-by") Question asked by #{question.User.username} 29 | 30 | if question.Answers.length 31 | each answer in question.Answers 32 | div(id='question-extra') 33 | div(class="remove-div" id='answer-box') 34 | p(class='answer-content' id=answer.id)= answer.content 35 | div(class='answer-footer') 36 | div(class='votes') 37 | +aUpvote(answer.id) 38 | +aDownvote(answer.id) 39 | if locals.user 40 | if locals.user.id === answer.userId 41 | button(class='edit-answer' id=answer.id) Edit 42 | button(class='delete-answer' id=answer.id) Delete 43 | 44 | form(class='answer-form' action=`/questions/${question.id}/answers`, method="post") 45 | input(name="questionId" value=question.id type="hidden") 46 | div(class="answer-form-textarea") 47 | textarea(name="content", cols="30", rows="10" placeholder="Write your answer here!") 48 | div 49 | button(type="submit" id="submit-answer") Submit Answer 50 | else 51 | div(id='redirect') 52 | h1 ☕Nothing to see here☕ 53 | a(href="/") Click Here To Go Back To The Home Page 54 | -------------------------------------------------------------------------------- /db/seeders/20210504175824-Answers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | /* 6 | Add altering commands here. 7 | Return a promise to correctly handle asynchronicity. 8 | 9 | Example: 10 | */ 11 | return queryInterface.bulkInsert('Answers', [ 12 | { 13 | content: "It is fresh air, oxygen to be specific, which robs the flavor from loose-leaf teas. Store the teas in an airtight container away from moisture and direct sunlight.", 14 | userId: 2, 15 | questionId: 1, 16 | createdAt: new Date(), 17 | updatedAt: new Date() 18 | }, 19 | { 20 | content: "No, fine tea should not be kept in the freezer. There are strong aromas and moisture in your freezer. Tea should be stored in a closed container, out of the light. Remember that tea is a blotter and will absorb strong smells!", 21 | userId: 2, 22 | questionId: 2, 23 | createdAt: new Date(), 24 | updatedAt: new Date() 25 | }, 26 | { 27 | content: "It is quite difficult to gauge how much caffeine is in a cup of tea, because it depends on so many factors: the tea itself, how much is used in a cup, and how long it is brewed. But the general rule is a cup of green tea contains about one-third as much caffeine as a cup of coffee, 40-60 mg per cup.", 28 | userId: 2, 29 | questionId: 3, 30 | createdAt: new Date(), 31 | updatedAt: new Date() 32 | }, 33 | { 34 | content: "Decaffeinated tea is tea from which the caffeine has been removed, through one of two possible decaffeination processes. Herbal tea, on the other hand, is not really tea at all, but is herbs brewed in the same way that tea is brewed. Herbals, sometimes referred to as tisanes, never had any caffeine to begin with.", 35 | userId: 2, 36 | questionId: 4, 37 | createdAt: new Date(), 38 | updatedAt: new Date() 39 | }, 40 | { 41 | content: "Oolong tea is sometimes referred to as Brown Tea, halfway between a black and green tea. In many respects it is the most complicated tea to make, because the tea is only partially oxidized. That is like keeping a banana perfectly ripe when nature wants to keep moving it toward being overripe. However the reward for all that hard work is tea with great body, and the most intense, varied aroma and flavors.", 42 | userId: 2, 43 | questionId: 5, 44 | createdAt: new Date(), 45 | updatedAt: new Date() 46 | }, 47 | { 48 | content: "All teas originate from the same species, the Camelia Sinensis. To make green tea, the fresh tea is briefly cooked using either steam or dry heat. This process fixes the green colors and fresh flavors. Black tea leaves are left outside and become limp (withered), then put into machines that roll the leaves and damage them. The damaged leaves change color to brown, then black. This natural process is called oxidation and is similar to the ripening of a banana (from yellow to brown and finally becoming black.) After all the tea is dried, it can be shipped great distances. The oxidation process changes the flavor of the tea (now black) and gives it more body.", 49 | userId: 2, 50 | questionId: 6, 51 | createdAt: new Date(), 52 | updatedAt: new Date() 53 | }, 54 | ], {}); 55 | }, 56 | 57 | down: (queryInterface, Sequelize) => { 58 | /* 59 | Add reverting commands here. 60 | Return a promise to correctly handle asynchronicity. 61 | 62 | Example: 63 | */ 64 | return queryInterface.bulkDelete('Answers', null, {}); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /public/stylesheets/question.css: -------------------------------------------------------------------------------- 1 | .question-body { 2 | background-color: #EFE4D4; 3 | min-height: 400px; 4 | margin-bottom: 100px; 5 | clear: both; 6 | } 7 | 8 | #page-container { 9 | /* padding-top: 2%; */ 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: space-around; 14 | } 15 | 16 | #question-box { 17 | display: flex; 18 | flex-direction: column; 19 | padding: 1%; 20 | height: 10%; 21 | width: 40%; 22 | background-color: white; 23 | border: solid 1px #A9A454; 24 | margin: 1%; 25 | } 26 | 27 | .question-footer { 28 | display: flex; 29 | justify-content: space-between; 30 | height: 5%; 31 | width: 100%; 32 | border-top: black 1px solid; 33 | padding: 2px; 34 | } 35 | 36 | .edit { 37 | height: 10%; 38 | width: 10%; 39 | align-self: flex-end; 40 | } 41 | 42 | .created-by { 43 | text-align: end; 44 | margin: 0; 45 | } 46 | 47 | #question-extra { 48 | padding: 1%; 49 | height: 10%; 50 | width: 40%; 51 | background-color: white; 52 | border: solid 1px #A9A454; 53 | } 54 | 55 | .edit-form { 56 | display: flex; 57 | flex-direction: column; 58 | } 59 | 60 | .edit-text-area { 61 | resize: none; 62 | height: 10%; 63 | font-size: 1rem; 64 | } 65 | 66 | .edit-submit { 67 | height: 7%; 68 | width: 30%; 69 | margin-top: 2px; 70 | align-self: flex-start; 71 | background-color: #b07946; 72 | border: 1px solid grey; 73 | cursor: pointer; 74 | font-size: 15px; 75 | } 76 | 77 | .edit-submit:hover { 78 | color: #EFE4D4; 79 | background-color: #A9A454; 80 | } 81 | 82 | #redirect { 83 | 84 | box-sizing: border-box; 85 | /* padding-top: 5%; */ 86 | height: 90vh; 87 | display: flex; 88 | flex-direction: column; 89 | justify-content: center; 90 | align-items: center; 91 | 92 | } 93 | 94 | #answer-box { 95 | margin: 5px; 96 | } 97 | 98 | .answer-form { 99 | padding: 20px; 100 | } 101 | 102 | .answer-form-textarea { 103 | text-align: center; 104 | font-size: 15px; 105 | outline: none; 106 | } 107 | 108 | #submit-answer { 109 | background-color: #b07946; 110 | border: 1px solid grey; 111 | cursor: pointer; 112 | height: 25px; 113 | font-size: 15px; 114 | } 115 | 116 | #submit-answer:hover { 117 | color: #EFE4D4; 118 | background-color: #A9A454; 119 | } 120 | 121 | .edit-answer { 122 | background-color: #b07946; 123 | border: 1px solid grey; 124 | cursor: pointer; 125 | height: 25px; 126 | font-size: 15px; 127 | } 128 | 129 | .edit-answer:hover { 130 | color: #EFE4D4; 131 | background-color: #A9A454; 132 | } 133 | 134 | .delete-answer { 135 | background-color: #b07946; 136 | border: 1px solid grey; 137 | cursor: pointer; 138 | height: 25px; 139 | font-size: 15px; 140 | margin: 5px; 141 | } 142 | 143 | .delete-answer:hover { 144 | color: #EFE4D4; 145 | background-color: #A9A454; 146 | } 147 | 148 | .edit { 149 | background-color: #b07946; 150 | border: 1px solid grey; 151 | cursor: pointer; 152 | height: 25px; 153 | font-size: 15px; 154 | } 155 | 156 | .edit:hover { 157 | color: #EFE4D4; 158 | background-color: #A9A454; 159 | } 160 | 161 | #submit-edit-button { 162 | background-color: #b07946; 163 | border: 1px solid grey; 164 | cursor: pointer; 165 | height: 25px; 166 | font-size: 15px; 167 | } 168 | 169 | #submit-edit-button:hover { 170 | color: #EFE4D4; 171 | background-color: #A9A454; 172 | } 173 | 174 | .votes { 175 | display: flex; 176 | justify-content: flex-start; 177 | align-items: center; 178 | } 179 | 180 | .vote-div { 181 | margin: 5px; 182 | display: flex; 183 | justify-content: flex-start; 184 | align-items: center; 185 | } 186 | -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bcrypt = require('bcryptjs'); 3 | const router = express.Router(); 4 | const { loginUser, logoutUser } = require('../auth') 5 | const { check, validationResult } = require('express-validator'); 6 | const { User } = require('../db/models') 7 | const { csrfProtection, asyncHandler } = require('./utils'); 8 | 9 | router.get('/', function (req, res, next) { 10 | res.send('respond with a resource'); 11 | }); 12 | 13 | router.get('/login', csrfProtection, asyncHandler(async (req, res) => { 14 | const user = await User.build(); 15 | res.render('login', { 16 | csrfToken: req.csrfToken(), 17 | title: "Login Page", 18 | user 19 | }) 20 | })); 21 | 22 | const loginValidators = [ 23 | check('username') 24 | .exists({ checkFalsy: true }) 25 | .withMessage('Please provide a value for Username') 26 | .isLength({ max: 20 }) 27 | .withMessage('Username must not be more than 20 characters long'), 28 | check('password') 29 | .exists({ checkFalsy: true }) 30 | .withMessage('Please provide a value for Password') 31 | .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])/, 'g') 32 | .withMessage('Password must contain at least 1 lowercase letter, uppercase letter, number, and special character (i.e. "!@#$%^&*")'), 33 | ]; 34 | 35 | router.post('/login', csrfProtection, loginValidators, asyncHandler(async (req, res) => { 36 | const { username, password } = req.body; 37 | 38 | let errors = []; 39 | const validatorErrors = validationResult(req); 40 | 41 | if (validatorErrors.isEmpty()) { 42 | const user = await User.findOne({ where: { username } }); 43 | 44 | if (user !== null) { 45 | const passwordMatch = await bcrypt.compare(password, user.hashedPassword.toString()); 46 | if (passwordMatch) { 47 | loginUser(req, res, user); 48 | req.session.save(() => { 49 | res.redirect("/") 50 | }); 51 | } 52 | else { 53 | errors.push("Login failed for the provided username and password.") 54 | res.render('login', { errors, title: "Login Page", csrfToken: req.csrfToken() }) 55 | } 56 | } 57 | else { 58 | errors.push("Login failed for the provided username and password.") 59 | res.render('login', { errors, title: "Login Page", csrfToken: req.csrfToken() }) 60 | } 61 | } 62 | else { 63 | errors = validatorErrors.array().map((error) => error.msg); 64 | res.render('login', { errors, title: "Login Page", csrfToken: req.csrfToken() }) 65 | } 66 | })); 67 | 68 | router.get('/signup', csrfProtection, asyncHandler(async (req, res) => { 69 | const user = await User.build(); 70 | res.render('signup', { 71 | csrfToken: req.csrfToken(), 72 | title: "Sign Up Page", 73 | user 74 | }); 75 | })); 76 | 77 | const signupValidators = [ 78 | check('email') 79 | .exists({ checkFalsy: true }) 80 | .withMessage('Please provide a value for Email') 81 | .isLength({ max: 50 }) 82 | .withMessage('Email must not be more than 50 characters long'), 83 | check('confirmPassword') 84 | .exists({ checkFalsy: true }) 85 | .withMessage('Please confirm Password') 86 | .custom((value, { req }) => { 87 | if (value !== req.body.password) { 88 | throw new Error('Confirm Password does not match Password'); 89 | } 90 | return true; 91 | }) 92 | ]; 93 | 94 | router.post('/signup', csrfProtection, loginValidators, signupValidators, asyncHandler(async (req, res) => { 95 | const { username, email, password } = req.body; 96 | const user = await User.build({ username, email }); 97 | 98 | const validatorErrors = validationResult(req); 99 | 100 | if (validatorErrors.isEmpty()) { 101 | const hashedPassword = await bcrypt.hash(password, 10); 102 | user.hashedPassword = hashedPassword; 103 | await user.save(); 104 | loginUser(req, res, user); 105 | req.session.save(() => res.redirect("/")); 106 | } else { 107 | const errors = validatorErrors.array().map((error) => error.msg); 108 | res.render('signup', { 109 | title: 'Sign Up', 110 | user, 111 | errors, 112 | csrfToken: req.csrfToken(), 113 | }); 114 | } 115 | 116 | })); 117 | 118 | router.post("/logout", (req, res) => { 119 | logoutUser(req, res); 120 | req.session.save(() => res.redirect("/")); 121 | }); 122 | 123 | router.post("/demo", asyncHandler(async (req, res) => { 124 | const user = await User.findByPk(1); 125 | 126 | loginUser(req, res, user); 127 | 128 | res.redirect("/questions") 129 | })); 130 | 131 | 132 | module.exports = router; 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kettle Overflow 2 | 3 | 4 | ![Screen Shot 2021-05-07 at 1 54 42 PM](https://user-images.githubusercontent.com/75630072/117512956-80984880-af45-11eb-856f-fa2380f842ed.png) 5 | 6 | [Kettle Overflow](https://kettle-overflow.herokuapp.com/) 7 | 8 | # Summary 9 | 10 | [Kettle Overflow](https://kettle-overflow.herokuapp.com/) is a web application inspired by Stack Overflow built using JavaScript using Express for the back end and Pug for the front end. Kettle Overflow allows users to: 11 | - Create an account 12 | ![Screen Shot 2021-05-07 at 1 57 12 PM](https://user-images.githubusercontent.com/78223925/117514226-feb21a80-af58-11eb-8fb9-a67ea7673c06.png) 13 | - Log in / Log out 14 | ![Screen Shot 2021-05-07 at 1 57 01 PM](https://user-images.githubusercontent.com/78223925/117514158-d5918a00-af58-11eb-9b74-0cc3635d8a9f.png) 15 | - Ask questions about Tea 16 | ![Screen Shot 2021-05-07 at 1 57 33 PM](https://user-images.githubusercontent.com/78223925/117514335-2ef9b900-af59-11eb-954a-98508b87a4a6.png) 17 | - Answer questions about Tea 18 | - Edit either questions or answers that belong to the user 19 | - Delete answers that belong to the user 20 | - Search for a question using the search bar 21 | ![Screen Shot 2021-05-07 at 1 56 25 PM](https://user-images.githubusercontent.com/78223925/117514392-53559580-af59-11eb-8c74-66e0ff89a566.png) 22 | 23 | ## Technologies Used 24 | 25 | - Express 26 | - Heroku 27 | - PostgreSQL 28 | - BCrypt 29 | - Pug 30 | 31 | ## Planning Stage 32 | 33 | - We planned out our [database](https://github.com/boothjacobs/KettleOverflow/wiki/Database-Schema) making sure that we made deliberate choices on which models were connected. 34 | - We wrote the [API routes](https://github.com/boothjacobs/KettleOverflow/wiki/API-Documentation) using RESTful naming conventions. 35 | - We talked through what we wanted the user experience to be. 36 | - We made a deliberate choice to not let users delete questions. 37 | - We created a [feature list](https://github.com/boothjacobs/KettleOverflow/wiki/Feature-List) that walks through how the user interacts with the features we planned to create. 38 | - We wrote the [frontend routes](https://github.com/boothjacobs/KettleOverflow/wiki/Frontend-Routes)with the user experience in mind. 39 | 40 | ## Future Features 41 | 42 | - Customizable profile for each account including profile photos 43 | - Organizing questions into categories 44 | - Tags attached to each question 45 | - Option to share images/videos in both questions and answers 46 | - Comments on Answers 47 | - Search result page includes up votes/down votes 48 | 49 | 50 | ## Particular Challenges 51 | 52 | ### Sarah: 53 | 54 | Implementing upvotes/downvotes turned out to be a massive project, involving 140 lines of JavaScript, five helper functions, and database calls in three different route handlers. The database request that we used to populate answers gave us access to total votes, but not an immediately accessible way to distinguish between upvotes and downvotes. The problem remained of how to display two different AnswerVote counts for each answer displaying on the page. At the time of this writing I think the best way to implement upvoting and downvoting on an indefinite number of responses per question would be to change the structure of the database so that up- and downvotes are separate models, which would allow for a single database call to immediately provide the current value of each. 55 | 56 | ### Lauren's Challenge 57 | In the event listener I wrote for the Edit Answer button, I found it difficult to grab both the ID and content of specific answers in the database. The answer content was being populated on the page through iteration in the pug file. This made it so there weren't specific classes or ids for each answer, so I set an id of "answer.id" on the edit button, and used event.target.id to grab the answer ID. To grab the correct answer content, I saved event.path[1].children[0] to a variable and then split that variable on the section of the innerHTML that started with "<". I assigned the split variable to "contentSplit" and then reassigned the textarea innerHTML to contentSplit[0]. 58 | 59 | Grabbing the corrent answer ID and content 60 | Screen Shot 2021-05-07 at 2 13 49 PM 61 | 62 | Assigning the correct content to the textarea 63 | Screen Shot 2021-05-07 at 2 14 03 PM 64 | 65 | ### Drew: 66 | 67 | This was the first time that we had to update the database using a PUT request, so we had difficulty connecting the request to the route that is in our express file. We found that the content we were trying to send was located on the req.body because it is sent in the PUT request's body. 68 | 69 | > Fetch request: 70 | ![fetch-put](https://user-images.githubusercontent.com/78223925/117510133-23a28f80-af51-11eb-9312-5570b093592e.PNG) 71 | 72 | 73 | > PUT router: 74 | ![router-put](https://user-images.githubusercontent.com/78223925/117510175-37e68c80-af51-11eb-8034-bb84bb78cde3.PNG) 75 | 76 | -------------------------------------------------------------------------------- /public/javascripts/voting-action.js: -------------------------------------------------------------------------------- 1 | const qUpvoteButton = document.querySelector(".question-up-vote"); 2 | const qDownvoteButton = document.querySelector(".question-down-vote"); 3 | const aUpvoteButton = document.querySelectorAll(".answer-up-vote"); 4 | const aDownvoteButton = document.querySelectorAll(".answer-down-vote"); 5 | 6 | const qUpVoteDiv = document.querySelector('.qUpVoteTally'); 7 | const qDownVoteDiv = document.querySelector('.qDownVoteTally'); 8 | 9 | if (qUpvoteButton !== null) { 10 | qUpvoteButton.addEventListener("click", async (e) => { 11 | e.preventDefault(); 12 | let questionId = e.target.id; 13 | const url = `/questions/${questionId}/votes`; 14 | const vote = { vote: true }; 15 | 16 | if (qUpvoteButton.clicked !== true) { 17 | const res = await fetch(url, { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | body: JSON.stringify(vote), 23 | }); 24 | const data = await res.json(); 25 | qUpVoteDiv.innerHTML = data.upvotes; 26 | qUpvoteButton.clicked = true; 27 | } else { 28 | const res = await fetch(url, { 29 | method: 'POST', 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | }, 33 | body: JSON.stringify(vote), 34 | }); 35 | const data = await res.json(); 36 | qUpVoteDiv.innerHTML = data.upvotes; 37 | } 38 | }); 39 | } 40 | 41 | if (qDownvoteButton !== null) { 42 | qDownvoteButton.addEventListener("click", async (e) => { 43 | e.preventDefault(); 44 | let questionId = e.target.id; 45 | const vote = {vote: false}; 46 | const url = `/questions/${questionId}/votes`; 47 | 48 | if (qDownvoteButton.clicked !== true) { 49 | const res = await fetch(url, { 50 | method: 'POST', 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | }, 54 | body: JSON.stringify(vote), 55 | }); 56 | const data = await res.json(); 57 | qDownVoteDiv.innerHTML = data.downvotes; 58 | qDownvoteButton.clicked = true; 59 | } else { 60 | const res = await fetch(url, { 61 | method: 'POST', 62 | headers: { 63 | 'Content-Type': 'application/json', 64 | }, 65 | body: JSON.stringify(vote), 66 | }); 67 | const data = await res.json(); 68 | qDownVoteDiv.innerHTML = data.downvotes; 69 | } 70 | 71 | }); 72 | } 73 | 74 | if (aUpvoteButton !== null) { 75 | aUpvoteButton.forEach((button) => { 76 | 77 | button.addEventListener("click", async (e) => { 78 | e.preventDefault(); 79 | let answerId = e.target.id; 80 | const aUpVoteDiv = document.querySelector(`.tally${answerId}`); 81 | const url = `/answers/${answerId}/votes`; 82 | const vote = { vote: true }; 83 | 84 | if (aUpvoteButton.clicked !== true) { 85 | const res = await fetch(url, { 86 | method: "POST", 87 | headers: { 88 | "Content-Type": "application/json", 89 | }, 90 | body: JSON.stringify(vote), 91 | }); 92 | const data = await res.json(); 93 | aUpVoteDiv.innerHTML = data.upvotes.length; 94 | aUpvoteButton.clicked = true; 95 | } else { 96 | const res = await fetch(url, { 97 | method: "POST", 98 | headers: { 99 | "Content-Type": "application/json", 100 | }, 101 | body: JSON.stringify(vote), 102 | }); 103 | const data = await res.json(); 104 | aUpVoteDiv.innerHTML = data.upvotes.length; 105 | } 106 | }); 107 | }) 108 | } 109 | 110 | if (aDownvoteButton !== null) { 111 | aDownvoteButton.forEach((button) => { 112 | 113 | button.addEventListener("click", async (e) => { 114 | e.preventDefault(); 115 | let answerId = e.target.id; 116 | const aDownVoteDiv = document.querySelector(`.tallyDown${answerId}`); 117 | const url = `/answers/${answerId}/votes`; 118 | const vote = {vote: false}; 119 | 120 | if (aDownvoteButton.clicked !== true) { 121 | const res = await fetch(url, { 122 | method: "POST", 123 | headers: { 124 | "Content-Type": "application/json", 125 | }, 126 | body: JSON.stringify(vote), 127 | }); 128 | const data = await res.json(); 129 | aDownVoteDiv.innerHTML = data.downvotes.length; 130 | aDownvoteButton.clicked = true; 131 | } else { 132 | const res = await fetch(url, { 133 | method: "POST", 134 | headers: { 135 | "Content-Type": "application/json", 136 | }, 137 | body: JSON.stringify(vote), 138 | }); 139 | const data = await res.json(); 140 | aDownVoteDiv.innerHTML = data.downvotes.length; 141 | } 142 | }); 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /routes/questions.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bcrypt = require('bcryptjs'); 3 | const router = express.Router(); 4 | const { requireAuth } = require('../auth') //Need login/ logout 5 | const { check, validationResult } = require('express-validator'); 6 | const { Question, User, Answer, QuestionVote, AnswerVote, sequelize, Sequelize } = require('../db/models') 7 | const { csrfProtection, asyncHandler } = require('./utils'); 8 | const { Op } = require("sequelize"); 9 | 10 | async function questionUpvotes(questionId) { 11 | const upVoteTally = await QuestionVote.count({ 12 | where: { 13 | [Op.and]: [ { questionId }, { upVote: true } ] 14 | } 15 | }); 16 | return upVoteTally; 17 | }; 18 | async function questionDownvotes(questionId) { 19 | const downVoteTally = await QuestionVote.count({ 20 | where: { 21 | [Op.and]: [ { questionId }, { upVote: false } ] 22 | } 23 | }); 24 | return downVoteTally; 25 | }; 26 | async function answerUpvotes(answerId) { 27 | const answerUpvotes = await AnswerVote.findAll({ 28 | where: { 29 | [Op.and]: [ { answerId }, { upVote: true } ] 30 | } 31 | }); 32 | return answerUpvotes; 33 | }; 34 | async function answerDownvotes(answerId) { 35 | const answerDownvotes = await AnswerVote.findAll({ 36 | where: { 37 | [Op.and]: [ { answerId }, { upVote: false } ] 38 | } 39 | }); 40 | return answerDownvotes; 41 | }; 42 | async function voteExists(questionId, userId) { 43 | const answer = await QuestionVote.findOne({ 44 | where: { 45 | [Op.and]: [ { questionId }, { userId } ] 46 | } 47 | }); 48 | return answer; 49 | }; 50 | 51 | router.get('/', csrfProtection, asyncHandler(async (req, res, next) => { 52 | const questions = await Question.findAll({ 53 | include: [User], 54 | order: [['createdAt', 'DESC']], 55 | limit: 10, 56 | }); 57 | const number = Math.ceil(Math.random() * 5).toString() 58 | res.render('home', { 59 | csrfToken: req.csrfToken(), 60 | questions, 61 | title: 'Questions page', 62 | number 63 | }) 64 | })); 65 | 66 | router.post('/', asyncHandler(async (req, res, next) => { 67 | const { content } = req.body; 68 | const questions = await Question.findAll({ 69 | include: [User], 70 | where: { 71 | content: { 72 | [Op.iLike]: `%${content}%`, 73 | } 74 | } 75 | }); 76 | 77 | res.render('home', { 78 | questions 79 | }); 80 | })); 81 | 82 | router.get('/form', requireAuth, csrfProtection, function (req, res, next) { 83 | res.render('question-form', { 84 | csrfToken: req.csrfToken(), 85 | title: "Question Form" 86 | }) 87 | }); 88 | 89 | const questionValidators = [ 90 | check('content') 91 | .exists({ checkFalsy: true }) 92 | .withMessage('The question field can not be empty') 93 | .isLength({ min: 10 }) 94 | .withMessage('Question must be at least 10 characters long'), 95 | ]; 96 | 97 | router.post('/form', requireAuth, csrfProtection, questionValidators, asyncHandler(async (req, res) => { 98 | const { userId } = req.session.auth 99 | const { content } = req.body; 100 | 101 | let errors = []; 102 | const validatorErrors = validationResult(req); 103 | 104 | if (validatorErrors.isEmpty()) { 105 | const question = await Question.create({ 106 | content, 107 | userId 108 | }) 109 | res.redirect(`/questions/${question.id}`) 110 | } 111 | else { 112 | errors = validatorErrors.array().map((error) => error.msg); 113 | res.render('question-form', { errors, title: "Question Form", csrfToken: req.csrfToken() }) 114 | } 115 | 116 | 117 | })) 118 | 119 | router.get('/:id(\\d+)', asyncHandler(async (req, res) => { 120 | const question = await Question.findByPk(req.params.id, 121 | { include: [Answer, User] }) 122 | 123 | const upvotes = await questionUpvotes(req.params.id); 124 | const downvotes = await questionDownvotes(req.params.id); 125 | 126 | const answers = await Answer.findAll({ 127 | where: { questionId: req.params.id } 128 | }); 129 | 130 | let answerVotes = {}; 131 | if (answers) { 132 | const ids = answers.map((ele) => ele.dataValues.id); 133 | for (let i = 0; i < ids.length; i++) { 134 | let upvotesA = await answerUpvotes(ids[i]); 135 | let downvotesA = await answerDownvotes(ids[i]); 136 | answerVotes[`${ids[i]}`] = upvotesA; 137 | answerVotes[`${ids[i]}`] = downvotesA; 138 | } 139 | } 140 | 141 | let title; 142 | if (!question) { 143 | title = 'Nothing To See Here' 144 | } else { 145 | title = question.content 146 | } 147 | res.render('question', { 148 | question, 149 | title, 150 | upvotes, 151 | downvotes, 152 | answerVotes, 153 | // answerKeys 154 | }); 155 | })); 156 | 157 | router.put('/:id(\\d+)', asyncHandler(async (req, res) => { 158 | const questionId = req.params.id; 159 | 160 | const question = await Question.findByPk(questionId); 161 | question.content = req.body.content; 162 | await question.save(); 163 | res.sendStatus(201); 164 | })); 165 | 166 | router.post('/:id(\\d+)/answers', requireAuth, asyncHandler(async (req, res) => { 167 | const { userId } = req.session.auth 168 | const questionId = req.params.id 169 | const { content } = req.body; 170 | await Answer.create({ 171 | content, 172 | userId, 173 | questionId 174 | }) 175 | 176 | res.redirect(`/questions/${questionId}`) 177 | })) 178 | 179 | 180 | router.post('/:id/votes', asyncHandler(async (req, res) => { 181 | const questionId = req.params.id; 182 | const { userId } = req.session.auth; 183 | const { vote } = req.body; 184 | 185 | const alreadyVote = await voteExists(questionId, userId); 186 | 187 | if (alreadyVote) { 188 | await alreadyVote.destroy(); 189 | } else { 190 | await QuestionVote.create({ 191 | upVote: vote, 192 | userId, 193 | questionId 194 | }); 195 | } 196 | 197 | const upvotes = await questionUpvotes(req.params.id); 198 | const downvotes = await questionDownvotes(req.params.id); 199 | 200 | const answers = await Answer.findAll({ 201 | where: { questionId: req.params.id } 202 | }); 203 | let upvotesA; 204 | let downvotesA; 205 | if (answers) { 206 | const ids = answers.map((ele) => ele.dataValues.id); 207 | for (let i = 0; i < ids.length; i++) { 208 | upvotesA = await answerUpvotes(ids[i]); 209 | downvotesA = await answerDownvotes(ids[i]); 210 | } 211 | } 212 | 213 | res.setHeader('Content-Type', 'application/json'); 214 | res.send({upvotes: upvotes, downvotes: downvotes, ansUpvotes: upvotesA, ansDownvotes: downvotesA}); 215 | 216 | })); 217 | 218 | 219 | 220 | module.exports = router; 221 | -------------------------------------------------------------------------------- /planning/project-wiki.md: -------------------------------------------------------------------------------- 1 | Feature List 2 | ## 1. Login, sign up 3 | * Users must be logged in to ask or answer a question 4 | * Home page for unlogged in users 5 | * On logging in, users are redirected to their previous page 6 | * Bonus: customizable profile for each account 7 | 8 | ## 2. Ask Questions 9 | * Logged in users can post a question using a text editor. 10 | * The user that created the question can edit: Stack Overflow does not allow deletion of questions for archival purposes 11 | * Bonus: categories/tags 12 | * Bonus: code snippets/images? 13 | 14 | ## 3.Answer Questions 15 | * Logged in users can post an answer (reply) to a question 16 | * The user that created the answer can edit and delete it 17 | * Reply threads--bonus? Requires a join table? 18 | * Bonus: answer as a guest 19 | 20 | ## 4. Search Question 21 | * Anyone can search and read questions 22 | * Search results page is limited to 10 results per page 23 | 24 | ## 5. Upvote/Downvote Answer 25 | * Logged in users can up- or downvote an answer 26 | * The user that voted can remove their vote 27 | * All users can see number of votes on a post 28 | 29 | 30 | 31 | User Stories 32 | ## Sign Up 33 | * As an unregistered user, I want to access a sign-up form so that I can register myself. 34 | * On the /signup page: 35 | 1. I want to easily enter my email, username, and password. 36 | 2. I want to be logged in at submission. 37 | * When I enter invalid data: 38 | 1. I would like to be informed about what information I need to alter. 39 | 2. I would like to be redirected back to the form. 40 | 41 | ## Log In 42 | * As a registered but unverified user, I want to access a log-in form. 43 | * On the /login page: 44 | 1. I want to easily enter my username and password. 45 | 2. I want to be logged in at submission. 46 | * When I enter invalid data: 47 | 1. I would like to be informed about what information I need to alter. 48 | 2. I would like to be redirected back to the form. 49 | 50 | ## Ask A Question 51 | * As a registered and verified user I would like to ask my question. 52 | * On the /question page: 53 | 1. I want to fill out a question-form. 54 | 2. I want to submit my question. 55 | * On the /question/:id page: 56 | 1. As the owner of the post, I want to click a button to edit my question if I made a mistake. 57 | 2. I want to make my edit and update the post. 58 | 59 | ## Answer A Question 60 | * As a registered and verified user I would like to answer a question. 61 | * On the /question/:id page: 62 | 1. I want to fill out an answer-form. 63 | 2. I want to submit my answer. 64 | * After submitting: 65 | 1. As the owner of the answer post, I want to click a button to edit my answer if I made a mistake. 66 | 2. I want to make my edit and update the post. 67 | 3. As the owner of the answer post, I want to delete my answer. 68 | 4. I want to be asked if I am certain that I want to delete my answer. 69 | 5. I want my answer to disappear. 70 | 71 | ## Search A Topic 72 | * As a user I would like to search for a specific topic. 73 | * On the navigation bar: 74 | 1. I want to type in the topic I am interested in. 75 | 2. I want to submit a request to search for that topic. 76 | * On the /results page: 77 | 1. I want to see a page that has up to 10 results for that search. 78 | 2. I want to select the result I am interested in and be redirected to that question. 79 | 80 | ## Upvote/Downvote 81 | * As a user I want to either upvote or downvote on either/both a post and/or an answer. 82 | * On the /question/:id page: 83 | 1. I want to click an upvote/downvote button. 84 | 2. As the owner of the vote, I want to remove my vote from the post. 85 | 86 | ## Log Out 87 | * As a registered and verified user I would like to log out of my account. 88 | * On the navigation bar: 89 | 1. I want to click a logout button. 90 | 2. I want to be asked if I am certain I want to log out in case I clicked accidentally. 91 | 3. I want to be redirected to the home page. 92 | 93 | 94 | 95 | Database Schema 96 | # Database Schema 97 | 98 | *** 99 | ## `user` 100 | 101 | *** 102 | | Column Name | Data Type| details | 103 | | ----------- | ----------- | -----------| 104 | | id | integer | not null, primary key | 105 | | username | string | not null | 106 | | email | string | not null, indexed, unique | 107 | | hashed password | string | not null | 108 | | created-at | datetime | not null | 109 | | updated-at | datetime | not null | 110 | 111 | ## `questions` 112 | *** 113 | | Column Name | Data Type | Details | 114 | | ----------- | --------- | ------- | 115 | | id | integer | not null, primary key | 116 | | content | string | not null | 117 | | userId | integer | not null, foreign key | 118 | | created-at | datetime | not null | 119 | | updated-at | datetime | not null | 120 | 121 | ## `answers` 122 | *** 123 | | Column Name | Data Type | Details | 124 | | ----------- | --------- | ------- | 125 | | id | integer | not null, primary key | 126 | | content | string | not null | 127 | | userId | integer | not null, foreign key | 128 | | questionId | integer | not null, foreign key | 129 | | created-at | datetime | not null | 130 | | updated-at | datetime | not null | 131 | 132 | ## `questionVote` 133 | *** 134 | | Column Name | Data Type | Details | 135 | | ----------- | --------- | ------- | 136 | | id | integer | not null, primary key | 137 | | upVote | boolean | not null | 138 | | userId | integer | not null, indexed, foreign key | 139 | | questionId | integer | indexed, foreign key | 140 | 141 | ## `answerVote` 142 | *** 143 | | Column Name | Data Type | Details | 144 | | ----------- | --------- | ------- | 145 | | id | integer | not null, primary key | 146 | | upVote | boolean | not null | 147 | | userId | integer | not null, indexed, foreign key | 148 | | answerId | integer | indexed, foreign key | 149 | 150 | 151 | 152 | API Documentation 153 | # API-Routes 154 | 155 | ## Answers 156 | DELETE (to remove answer) 157 | * /answers/:id 158 | 159 | ## Question-Vote 160 | POST (to create new vote--up vs down represented by a Boolean) 161 | * /questions/:id/upVotes 162 | 163 | DELETE (to remove vote) 164 | * /questions/:id/upVotes 165 | 166 | ## Answer-Vote 167 | POST (to create new vote) 168 | * /answers/:id/upVotes 169 | 170 | DELETE (to remove vote) 171 | * /answers/:id/upVotes 172 | 173 | 174 | Front End Routes 175 | # User-Facing Routes 176 | 177 | *** 178 | ## `/` 179 | 180 | Display most recent questions or highest liked questions based off of filter, as well as log-in or sign-up if needed. Will also have a button to post a question and a search bar. 181 | * `GET /` 182 | 183 | *** 184 | ## `/login` 185 | 186 | Display a log-in form for user. 187 | * `GET /users/login` 188 | * `POST /users/login` 189 | 190 | *** 191 | ## `/signup` 192 | Display a sign-up form for the user. 193 | * `GET /users/signup` 194 | * `POST /users/signup` 195 | *** 196 | As a possible bonus feature, display an "edit profile" form for the user to update their information. 197 | * `GET /users/:id` 198 | * `PUT /users/:id` 199 | 200 | *** 201 | ## `/questions` 202 | 203 | Display a post question form for the user and a search bar above all posted questions. 204 | * `GET /questions` 205 | * `POST /questions` 206 | 207 | *** 208 | ## `/questions/:id` 209 | Displays a selected question to user, as well as provide an answer form to respond to. If the current user is the owner of that question (or answer), they can edit it. 210 | * `GET /questions/:id` 211 | * `PUT /questions/:id` 212 | * `POST /answers` 213 | * `PUT /answers/:id` 214 | 215 | Features a button to allow a user to delete an answer they've posted. 216 | Also allows the user to upvote/downvote the question or any of the answers corresponding to it. 217 | --------------------------------------------------------------------------------