├── bin ├── setup ├── deploy-heroku.sh ├── build-branch.sh └── mkapplink.js ├── public ├── favicon.ico ├── images │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ └── 9.jpg └── index.html ├── .babelrc ├── app ├── utils │ └── priceFormatter.js ├── components │ ├── Root.jsx │ ├── Home │ │ └── Home.jsx │ ├── NotFound.jsx │ ├── Navbar │ │ ├── UserIcon.jsx │ │ ├── SiteName.jsx │ │ ├── NavLink.jsx │ │ └── index.jsx │ ├── Authentication │ │ ├── WhoAmI.jsx │ │ ├── OAuth.jsx │ │ ├── Login.jsx │ │ └── Authenticate.jsx │ ├── Cart │ │ ├── CartItem.jsx │ │ ├── CheckoutButton.jsx │ │ └── Cart.jsx │ ├── SingleProduct │ │ ├── CartButton.jsx │ │ └── SingleProduct.jsx │ └── Products │ │ ├── Item.jsx │ │ └── AllProducts.jsx ├── reducers │ ├── index.jsx │ ├── auth.jsx │ ├── product.jsx │ ├── user.jsx │ └── order.jsx ├── store.jsx └── main.jsx ├── .gitignore ├── README.md ├── server ├── api.js ├── auth.filters.js ├── products.js ├── items.js ├── users.js ├── reviews.js ├── orders.js ├── start.js └── auth.js ├── db ├── models │ ├── order.js │ ├── review.js │ ├── product.js │ ├── item.js │ ├── index.js │ ├── user.js │ └── oauth.js ├── index.js └── seed.js ├── tests ├── server │ ├── orders.test.js │ ├── reviews.test.js │ ├── auth.test.js │ └── users.test.js ├── db │ ├── order.test.js │ ├── item.test.js │ ├── user.test.js │ ├── review.test.js │ └── product.test.js ├── components │ ├── WhoAmI.test.jsx │ ├── Login.test.jsx │ └── Cart.test.js └── reducers │ └── product.test.js ├── LICENSE ├── webpack.config.js ├── .eslintrc.js ├── index.js ├── gitWorkFlow.mdown ├── dev.js └── package.json /bin/setup: -------------------------------------------------------------------------------- 1 | mkapplink.js -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/1.jpg -------------------------------------------------------------------------------- /public/images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/2.jpg -------------------------------------------------------------------------------- /public/images/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/3.jpg -------------------------------------------------------------------------------- /public/images/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/4.jpg -------------------------------------------------------------------------------- /public/images/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/5.jpg -------------------------------------------------------------------------------- /public/images/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/6.jpg -------------------------------------------------------------------------------- /public/images/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/7.jpg -------------------------------------------------------------------------------- /public/images/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/8.jpg -------------------------------------------------------------------------------- /public/images/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielwr/Climb-Shopper/HEAD/public/images/9.jpg -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015", 5 | "stage-2" 6 | ] 7 | } -------------------------------------------------------------------------------- /app/utils/priceFormatter.js: -------------------------------------------------------------------------------- 1 | `use strict` 2 | 3 | export default price => { 4 | price = price ? (price / 100) : 0 5 | return price.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all node_modules 2 | node_modules/* 3 | 4 | # ...except the symlink to ourselves. 5 | !node_modules/APP 6 | 7 | # Compiled JS 8 | public/bundle.js 9 | public/bundle.js.map 10 | 11 | # NPM errors 12 | npm-debug.log 13 | 14 | #Webstorm ignore 15 | .idea/ 16 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /app/components/Root.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Navbar from './Navbar/' 3 | 4 | /*------------------- COMPONENT -----------------*/ 5 | const Root = ({ children }) => ( 6 |
7 | 8 | { children } 9 |
10 | ) 11 | 12 | export default Root 13 | -------------------------------------------------------------------------------- /app/components/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { login } from 'APP/app/reducers/auth' 4 | 5 | /* ----------------- COMPONENT ------------------ */ 6 | const Home = props => ( 7 |
8 |

Welcome to Climb Shopper

9 |
10 | ) 11 | 12 | export default Home 13 | 14 | -------------------------------------------------------------------------------- /app/reducers/index.jsx: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | const rootReducer = combineReducers({ 4 | auth: require('./auth').default, 5 | order: require('./order').default, 6 | product: require('./product').default, 7 | user: require('./user').default, 8 | }) 9 | 10 | export default rootReducer 11 | -------------------------------------------------------------------------------- /bin/deploy-heroku.sh: -------------------------------------------------------------------------------- 1 | # By default, we git push our build branch to heroku master. 2 | # You can specify DEPLOY_REMOTE and DEPLOY_BRANCH to configure 3 | # this. 4 | deploy_remote="${DEPLOY_REMOTE:-heroku}" 5 | deploy_branch="${DEPLOY_BRANCH:-master}" 6 | 7 | deploy() { 8 | git push -f "$deploy_remote" "$branch_name:$deploy_branch" 9 | } 10 | 11 | . "$(dirname $0)/build-branch.sh" 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub issues](https://img.shields.io/github/issues/gabrielwr/Climb-Shopper.svg?style=flat-square)](https://github.com/gabrielwr/Climb-Shopper/issues) 2 | [![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/gabrielwr/Climb-Shopper.svg?style=flat-square)](https://github.com/gabrielwr/Climb-Shopper/pulls?q=is%3Apr+is%3Aclosed) 3 | 4 | # Climb-Shopper 5 | Buy yourself a nice climbing area! 6 | 7 | Currently in development! 8 | -------------------------------------------------------------------------------- /app/store.jsx: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import { composeWithDevTools } from 'redux-devtools-extension' 3 | import rootReducer from './reducers' 4 | import createLogger from 'redux-logger' 5 | import thunkMiddleware from 'redux-thunk' 6 | 7 | const store = createStore( 8 | rootReducer, 9 | composeWithDevTools( 10 | applyMiddleware( 11 | createLogger({collapsed: true}), 12 | thunkMiddleware 13 | ) 14 | ) 15 | ) 16 | 17 | export default store 18 | -------------------------------------------------------------------------------- /server/api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const api = module.exports = require('express').Router() 4 | 5 | api 6 | .get('/heartbeat', (req, res) => res.send({ok: true})) 7 | .use('/auth', require('./auth')) 8 | .use('/orders', require('./orders')) 9 | .use('/users', require('./users')) 10 | .use('/reviews', require('./reviews')) 11 | .use('/products', require('./products')) 12 | .use('/items', require('./items')) 13 | 14 | // No routes matched? 404. 15 | api.use((req, res) => res.status(404).end()) 16 | -------------------------------------------------------------------------------- /db/models/order.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Sequelize = require('sequelize') 3 | const db = require('./index.js') 4 | 5 | module.exports = db => db.define('orders', { 6 | status: { 7 | type: Sequelize.ENUM('Pending', 'Complete'), 8 | defaultValue: 'Pending' 9 | } 10 | }, { 11 | defaultScope: { 12 | include: [{ 13 | model: db.model('items') 14 | }] 15 | } 16 | }) 17 | 18 | module.exports.associations = (Order, { Item, User }) => { 19 | Order.hasMany(Item) 20 | Order.belongsTo(User) 21 | } 22 | -------------------------------------------------------------------------------- /tests/server/orders.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'), 2 | { expect } = require('chai'), 3 | db = require('APP/db'), 4 | app = require('APP/server/start') 5 | 6 | /* global describe it before afterEach */ 7 | 8 | describe('/api/orders', () => { 9 | before('Await database sync', () => db.didSync) 10 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 11 | 12 | describe('GET /:id', () => { 13 | describe('when not logged in', () => 14 | it('fails with a 401 (Unauthorized)', () => 15 | request(app) 16 | .get(`/api/orders/1`) 17 | .expect(401) 18 | )) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /db/models/review.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Sequelize = require('sequelize') 3 | 4 | module.exports = db => db.define('reviews', { 5 | title: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | validate: { 9 | notEmpty: true 10 | } 11 | }, 12 | content: { 13 | type: Sequelize.TEXT, 14 | allowNull: false 15 | }, 16 | num_stars: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | validate: { 20 | min: 1, 21 | max: 5 22 | } 23 | } 24 | }) 25 | 26 | module.exports.associations = (Review, { Product, User }) => { 27 | Review.belongsTo(User) 28 | Review.belongsTo(Product) 29 | } 30 | -------------------------------------------------------------------------------- /app/components/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | 4 | const NotFound = props => { 5 | const { pathname } = props.location || { pathname: '<< no path >>' } 6 | console.error('NotFound: %s not found (%o)', pathname, props) 7 | return ( 8 |
9 |

Sorry, I couldn't find
{ pathname }

10 |

The router gave me these props:

11 |
12 |         { JSON.stringify( props, null, 2 ) }
13 |       
14 |

Lost? Here's a way home.

15 | ~ xoxo, bones. 16 |
17 | ) 18 | } 19 | 20 | export default NotFound 21 | -------------------------------------------------------------------------------- /app/components/Navbar/UserIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import styled from 'styled-components' 4 | 5 | /* ----------------- STYLED COMPONENT ------------------ */ 6 | const Div = styled.div` 7 | display: flex; 8 | flex-wrap: nowrap; 9 | align-items: center; 10 | color: ${ props => props.theme.text ? props.theme.text : 'black' }; 11 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 12 | 13 | ` 14 | /* ----------------- COMPONENT ------------------ */ 15 | const UserIcon = () => ( 16 |
17 | {/* user icon */} 18 |
19 | ) 20 | 21 | export default UserIcon; 22 | -------------------------------------------------------------------------------- /app/components/Authentication/WhoAmI.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { logout } from 'APP/app/reducers/auth' 5 | 6 | /* ----------------- COMPONENT ------------------ */ 7 | export const WhoAmI = ({ user, logout }) => ( 8 |
9 | { user && user.name } 10 | 11 |
12 | ) 13 | 14 | /* ----------------- CONTAINER ------------------ */ 15 | const mapState = ({ auth }) => ({ user: auth }) 16 | const mapDispatch = { logout } 17 | 18 | export default connect( mapState, mapDispatch )( WhoAmI ) 19 | -------------------------------------------------------------------------------- /db/models/product.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Sequelize = require('sequelize') 4 | 5 | module.exports = db => db.define('products', { 6 | name: { 7 | type: Sequelize.STRING, 8 | allowNull: false, 9 | validate: { 10 | notEmpty: true, 11 | } 12 | }, 13 | category: { 14 | type: Sequelize.STRING, 15 | allowNull: false 16 | }, 17 | price: { 18 | type: Sequelize.INTEGER, 19 | allowNull: false, 20 | validate: { 21 | min: 0 22 | } 23 | }, 24 | images: { 25 | type: Sequelize.STRING, 26 | }, 27 | description: { 28 | type: Sequelize.TEXT, 29 | allowNull: false 30 | } 31 | }) 32 | 33 | module.exports.associations = (Product, { Review }) => { 34 | Product.hasMany(Review) 35 | } 36 | -------------------------------------------------------------------------------- /db/models/item.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Sequelize = require('sequelize') 4 | 5 | // Including some additional flexibility in case a user logged in with oAuth 6 | module.exports = db => db.define('items', { 7 | price: { 8 | type: Sequelize.INTEGER, 9 | allowNull: false, 10 | validate: { 11 | min: 0 12 | } 13 | }, 14 | quantity: { 15 | type: Sequelize.INTEGER, 16 | allowNull: false, 17 | validate: { 18 | min: 0, 19 | isInt: true 20 | } 21 | } 22 | }, { 23 | defaultScope: { 24 | include: [{ 25 | model: db.model('products'), 26 | }] 27 | } 28 | }) 29 | 30 | module.exports.associations = (Item, { Order, Product }) => { 31 | Item.belongsTo(Order) 32 | Item.belongsTo(Product) 33 | } 34 | -------------------------------------------------------------------------------- /app/components/Cart/CartItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { Link, browserHistory } from 'react-router' 4 | import styled from 'styled-components' 5 | 6 | const HeaderCell = styled.th` 7 | padding: 1rem; 8 | margin: 1rem; 9 | min-width: 8rem; 10 | ` 11 | 12 | const Cell = styled.td` 13 | padding: 1rem; 14 | margin: 1rem; 15 | border-size: 1em; 16 | ` 17 | 18 | const TFRow = styled.th` 19 | padding: 1rem; 20 | margin: 1rem; 21 | min-width: 8rem; 22 | ` 23 | 24 | const Cart = ({ type, content }) => { 25 | if (type === 'th') { 26 | return { content } 27 | } else if (type === 'tf') { 28 | return { content } 29 | } else if (type === 'td') { 30 | return { content } 31 | } 32 | } 33 | 34 | export default Cart 35 | -------------------------------------------------------------------------------- /app/components/Cart/CheckoutButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import FontAwesome from 'react-fontawesome' 4 | import styled from 'styled-components' 5 | import { Flex, Box } from 'grid-styled' 6 | import formatPrice from 'APP/app/utils/priceFormatter' 7 | 8 | 9 | /* ----------------- STYLED COMPONENT ------------------ */ 10 | const Div = styled.div` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | background-color: black; 15 | border-radius: 4%; 16 | color: white; 17 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 18 | &:hover { 19 | background-color: grey; 20 | } 21 | ` 22 | 23 | export default ({ handleClick, text, iconName }) => ( 24 |
25 | 26 |  { text } 27 |
28 | ) 29 | -------------------------------------------------------------------------------- /app/components/Authentication/OAuth.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styled from 'styled-components' 4 | 5 | /* ----------------- STYLED COMPONENTS ------------------ */ 6 | const Input = styled.input` 7 | display: block; 8 | margin-bottom: 1rem; 9 | ` 10 | 11 | const OAuthDiv = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: flex-end; 15 | padding-left: 3rem; 16 | border-left: .1rem solid black; 17 | ` 18 | 19 | /* ----------------- COMPONENT ------------------ */ 20 | const OAuth = () => ( 21 |
22 | 23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | ) 32 | 33 | export default OAuth 34 | -------------------------------------------------------------------------------- /server/auth.filters.js: -------------------------------------------------------------------------------- 1 | const mustBeLoggedIn = (req, res, next) => { 2 | if (!req.user) { 3 | return res.status(401).send('You must be logged in') 4 | } 5 | next() 6 | } 7 | 8 | const forbidden = message => (req, res) => { 9 | res.status(403).send(message) 10 | } 11 | 12 | const orderBelongsToUser = (order) => (req, res, next) => { 13 | if (order.user_id === req.user.id || req.user.is_admin) { 14 | next() 15 | } 16 | res.status(403).send('Forbidden') 17 | } 18 | 19 | // Feel free to add more filters here (suggested: something that keeps out non-admins) 20 | // We assume that req.user is an instance of a sequelize user, 21 | // meaning they will have access to isAdmin bool column. 22 | const mustBeAdmin = (req, res, next) => { 23 | if (!req.user.is_admin) { 24 | return res.status(403).send(`You can only do this if you're an admin`) 25 | } 26 | next() 27 | } 28 | 29 | module.exports = { mustBeLoggedIn, forbidden, mustBeAdmin, orderBelongsToUser } 30 | -------------------------------------------------------------------------------- /app/components/Navbar/SiteName.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import styled from 'styled-components' 4 | 5 | /* ----------------- STYLED COMPONENTS ------------------ */ 6 | const NavLink = styled(Link)` 7 | display: flex; 8 | align-items: center; 9 | color: black; 10 | padding-left: 3rem; 11 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 12 | text-decoration: none; 13 | 14 | &:hover { 15 | color: lightgreen; 16 | text-decoration: underline; 17 | } 18 | 19 | @media (max-width: 768px) { 20 | padding-top: .5rem; 21 | padding-left: 0; 22 | justify-content: center; 23 | min-height: 100%; 24 | } 25 | ` 26 | 27 | //TODO: 28 | // Update font-family to look prettier 29 | 30 | /* ----------------- COMPONENT ------------------ */ 31 | const SiteName = () => ( 32 | 33 | Climb Shopper 34 | 35 | ) 36 | 37 | export default SiteName; 38 | -------------------------------------------------------------------------------- /tests/server/reviews.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'), 2 | { expect } = require('chai'), 3 | db = require('APP/db'), 4 | app = require('APP/server/start') 5 | 6 | /* global describe it before afterEach */ 7 | 8 | describe('/api/review', () => { 9 | before('Await database sync', () => db.didSync) 10 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 11 | 12 | describe('GET', () => 13 | describe('get the reviews', () => { 14 | it('fails with a 403 (Forbidden)', () => 15 | request(app) 16 | .get(`/api/reviews/`) 17 | .expect(403) 18 | ) 19 | })) 20 | 21 | describe('POST', () => 22 | describe('when not logged in', () => { 23 | it('creates a user', () => 24 | request(app) 25 | .post('/api/reviews') 26 | .send({ 27 | title: 'Dope Bikez', 28 | content: 'This bike is so dope, it is a firecracker under my keister (sp?)', 29 | num_stars: 5 30 | }) 31 | .expect(201)) 32 | })) 33 | }) 34 | -------------------------------------------------------------------------------- /app/components/SingleProduct/CartButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import FontAwesome from 'react-fontawesome' 4 | import styled from 'styled-components' 5 | import { Flex, Box } from 'grid-styled' 6 | import formatPrice from 'APP/app/utils/priceFormatter' 7 | 8 | 9 | 10 | /* ----------------- STYLED COMPONENT ------------------ */ 11 | const Div = styled.div` 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | background-color: grey; 16 | color: ${ props => props.theme.text ? props.theme.text : 'white' }; 17 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 18 | &:hover { 19 | background-color: lightgrey; 20 | } 21 | ` 22 | 23 | 24 | export default ({ handleClick, text, iconName }) => ( 25 | 26 | 27 |
28 | 29 |  { text } 30 |
31 |
32 |
33 | ) 34 | -------------------------------------------------------------------------------- /tests/db/order.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db'), 4 | { Order } = db, 5 | { expect } = require('chai'), 6 | Promise = require('bluebird') 7 | 8 | describe('The `Order` model', () => { 9 | /** 10 | * First we clear the database and recreate the tables before beginning a run 11 | */ 12 | before('Await database sync', () => db.didSync) 13 | 14 | /** 15 | * Next, we create an (un-saved!) order instance before every spec 16 | */ 17 | let order 18 | beforeEach(() => { 19 | order = Order.build({ 20 | status: 'Pending', 21 | }) 22 | }) 23 | 24 | /** 25 | * Also, we empty the tables after each spec 26 | */ 27 | afterEach(() => { 28 | return Promise.all([ 29 | Order.truncate({ cascade: true }) 30 | ]) 31 | }) 32 | 33 | describe('attributes definition', function() { 34 | 35 | it('included `status` fields', function() { 36 | return order.save() 37 | .then(function(savedOrder) { 38 | expect(savedOrder.status).to.equal('Pending') 39 | }) 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 Fullstack Academy of Code 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /app/components/Authentication/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { login } from 'APP/app/reducers/auth' 5 | 6 | import styled from 'styled-components' 7 | 8 | /* ----------------- STYLED COMPONENTS ------------------ */ 9 | const Input = styled.input` 10 | display: block; 11 | margin-bottom: 1rem; 12 | ` 13 | 14 | const Form = styled.form` 15 | display: flex; 16 | flex-direction: column; 17 | padding-right: 3rem; 18 | ` 19 | 20 | /* ----------------- COMPONENT ------------------ */ 21 | export const Login = ({ loginUser }) => ( 22 |
{ 23 | evt.preventDefault() 24 | loginUser( evt.target.username.value, evt.target.password.value ) 25 | {/* should be .then here to update with icon */} 26 | }}> 27 | 28 | 29 | 30 |
31 | ) 32 | 33 | /* ----------------- CONTAINER ------------------ */ 34 | const mapState = state => ({}) 35 | const mapDispatch = { loginUser: login } 36 | 37 | export default connect( mapState, mapDispatch )( Login ) 38 | -------------------------------------------------------------------------------- /app/components/Authentication/Authenticate.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import Login from './Login' 5 | import OAuth from './OAuth' 6 | 7 | import { login, logout } from 'APP/app/reducers/auth' 8 | 9 | import styled from 'styled-components' 10 | 11 | /* ----------------- STYLED COMPONENTS ------------------ */ 12 | const LoginDiv = styled.div` 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: center; 16 | align-items: center; 17 | height: 20rem; 18 | ` 19 | 20 | /* ----------------- COMPONENT ------------------ */ 21 | export const Authenticate = ({ login, logout }) => ( 22 | 23 | 24 | 25 | 26 | ) 27 | 28 | /* 29 | DESIGN NOTE: 30 | If logged in, should only display "sign out" in dropdown menu. 31 | -> this should then use toast to alert that you are logged out 32 | Otherwise, display "sign in" which routes to modal 33 | -> use toast after successfully signed in 34 | */ 35 | 36 | /* ----------------- CONTAINER ------------------ */ 37 | const mapState = state => ({}) 38 | const mapDispatch = { login, logout } 39 | 40 | export default connect( mapState, mapDispatch )( Authenticate ) 41 | -------------------------------------------------------------------------------- /app/components/Navbar/NavLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FontAwesome from 'react-fontawesome' 3 | import { Link } from 'react-router' 4 | import styled from 'styled-components' 5 | 6 | 7 | /* ----------------- STYLED COMPONENT ------------------ */ 8 | const NavLink = styled(Link)` 9 | display: flex; 10 | flex-wrap: nowrap; 11 | align-items: center; 12 | background: ${ props => props.theme.primary ? props.theme.primary : 'white' }; 13 | color: ${ props => props.theme.text ? props.theme.text : 'black' }; 14 | margin-right: 3rem; 15 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 16 | text-decoration: none; 17 | 18 | &:hover { 19 | background-color: white; 20 | color: lightgreen; 21 | } 22 | 23 | @media (max-width: 768px) { 24 | padding-left: 1rem; 25 | padding-right: 1rem; 26 | justify-content: center; 27 | min-height: 100%; 28 | } 29 | ` 30 | 31 | /* ----------------- COMPONENT ------------------ */ 32 | export default ({ to, name, logo, onClick }) => ( 33 | 38 | { logo && } 39 |  { name } 40 | 41 | ) 42 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const LiveReloadPlugin = require('webpack-livereload-plugin') 4 | , devMode = require('.').isDevelopment 5 | 6 | /** 7 | * Fast source maps rebuild quickly during development, but only give a link 8 | * to the line where the error occurred. The stack trace will show the bundled 9 | * code, not the original code. Keep this on `false` for slower builds but 10 | * usable stack traces. Set to `true` if you want to speed up development. 11 | */ 12 | 13 | , USE_FAST_SOURCE_MAPS = false 14 | 15 | module.exports = { 16 | entry: './app/main.jsx', 17 | output: { 18 | path: __dirname, 19 | filename: './public/bundle.js' 20 | }, 21 | context: __dirname, 22 | devtool: devMode && USE_FAST_SOURCE_MAPS 23 | ? 'cheap-module-eval-source-map' 24 | : 'source-map', 25 | resolve: { 26 | extensions: ['.js', '.jsx', '.json', '*'] 27 | }, 28 | module: { 29 | rules: [{ 30 | test: /jsx?$/, 31 | exclude: /(node_modules|bower_components)/, 32 | use: [{ 33 | loader: 'babel-loader', 34 | options: { 35 | presets: ['react', 'es2015', 'stage-2'] 36 | } 37 | }] 38 | }] 39 | }, 40 | plugins: devMode 41 | ? [new LiveReloadPlugin({appendScriptTag: true})] 42 | : [] 43 | } 44 | -------------------------------------------------------------------------------- /server/products.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const db = require('APP/db') 3 | const Product = db.model('products') 4 | const { forbidden, mustBeAdmin } = require('./auth.filters') 5 | 6 | module.exports = require('express').Router() 7 | .get('/', 8 | (req, res, next) => 9 | Product.findAll() 10 | .then(products => res.json(products)) 11 | .catch(next)) 12 | .post('/', 13 | mustBeAdmin, 14 | (req, res, next) => 15 | Product.create(req.body) 16 | .then(product => res.status(201).json(product)) 17 | .catch(next)) 18 | .get('/:id', 19 | (req, res, next) => 20 | Product.findById(req.params.id) 21 | .then(product => res.json(product)) 22 | .catch(next)) 23 | .put('/:id', 24 | mustBeAdmin, 25 | (req, res, next) => 26 | Product.findById(req.params.id) 27 | .then(product => product.update(req.body)) 28 | .then(updatedProduct => res.json(updatedProduct)) 29 | .catch(next)) 30 | .delete('/:id', 31 | mustBeAdmin, 32 | (req, res, next) => 33 | Product.findById(req.params.id) 34 | .then(product => product.destroy()) 35 | .then(wasDestroyedBool => { 36 | if (wasDestroyedBool) { 37 | res.sendStatus(204) 38 | } else { 39 | const err = Error('Product not destroyed') 40 | err.status = 400 41 | throw err 42 | } 43 | }) 44 | .catch(next)) 45 | -------------------------------------------------------------------------------- /app/components/Products/Item.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Link } from 'react-router' 4 | import styled from 'styled-components' 5 | import formatPrice from 'APP/app/utils/priceFormatter' 6 | 7 | const ProductLink = styled(Link)` 8 | display: flex; 9 | align-items: center; 10 | border-style: solid; 11 | border-color: lightgrey; 12 | color: black; 13 | max-width: 500px; 14 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 15 | text-decoration: none; 16 | &:hover { 17 | background-color: lightgrey; 18 | } 19 | ` 20 | 21 | const Article = styled.article` 22 | display: flex; 23 | flex-direction: column; 24 | width: 100%; 25 | color: ${ props => props.theme.text ? props.theme.text : 'black' }; 26 | background-color: ${ props => props.theme.bg ? props.theme.bg : 'white' }; 27 | ` 28 | 29 | const Img = styled.img` 30 | height: 100%; 31 | background-repeat: no-repeat; 32 | max-width: 500px; 33 | padding-bottom: 62.5%; 34 | &:hover { 35 | opacity: 10%; 36 | background-color: lightgrey; 37 | } 38 | ` 39 | 40 | export default ({ productId, name, price, image, altText }) => { 41 | return ( 42 | 43 |
44 | 45 |
46 |

{ name }

47 |

{ formatPrice( price ) }

48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /server/items.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const db = require('APP/db') 3 | const Item = db.model('items') 4 | const { mustBeLoggedIn, forbidden } = require('./auth.filters') 5 | 6 | module.exports = require('express').Router() 7 | .get('/', 8 | forbidden('listing Items is not allowed'), 9 | (req, res, next) => 10 | Item.findAll() 11 | .then(items => res.json(items)) 12 | .catch(next)) 13 | .post('/', 14 | (req, res, next) => 15 | Item.create(req.body) 16 | .then(item => res.status(201).json(item)) 17 | .catch(next)) 18 | .get('/:id', 19 | mustBeLoggedIn, 20 | (req, res, next) => 21 | Item.findById(req.params.id) 22 | .then(item => res.json(item)) 23 | .catch(next)) 24 | .put('/:id', 25 | mustBeLoggedIn, 26 | (req, res, next) => 27 | Item.findById(req.params.id) 28 | .then(item => item.update(req.body)) 29 | .then(updatedItem => res.json(updatedItem)) 30 | .catch(next)) 31 | .delete('/:id', 32 | // TO DO: make sure that this user is Admin 33 | mustBeLoggedIn, 34 | (req, res, next) => 35 | Item.findById(req.params.id) 36 | .then(item => item.destroy()) 37 | .then(wasDestroyedBool => { 38 | if (wasDestroyedBool) { 39 | res.sendStatus(202) 40 | } else { 41 | const err = Error('Item not destroyed') 42 | err.status = 400 43 | throw err 44 | } 45 | }) 46 | .catch(next)) 47 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "eslint-config-standard", 3 | root: true, 4 | parser: "babel-eslint", 5 | parserOptions: { 6 | sourceType: "module", 7 | ecmaVersion: 8 8 | }, 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | plugins: ['react'], 13 | rules: { 14 | "space-before-function-paren": ["error", "never"], 15 | "prefer-const": "warn", 16 | "comma-dangle": ["error", "only-multiline"], 17 | "space-infix-ops": "off", // Until eslint #7489 lands 18 | "new-cap": "off", 19 | "no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], 20 | "no-return-assign": "off", 21 | "no-unused-expressions": "off", 22 | "one-var": "off", 23 | "new-parens": "off", 24 | "indent": ["error", 2, {SwitchCase: 0}], 25 | "arrow-body-style": ["warn", "as-needed"], 26 | 27 | "no-unused-vars": "off", 28 | "react/jsx-uses-react": "error", 29 | "react/jsx-uses-vars": "error", 30 | "react/react-in-jsx-scope": "error", 31 | 32 | "import/first": "off", 33 | 34 | // This rule enforces a comma-first style, such as 35 | // npm uses. I think it's great, but it can look a bit weird, 36 | // so we're leaving it off for now (although stock Bones passes 37 | // the linter with it on). If you decide you want to enforce 38 | // this rule, change "off" to "error". 39 | "comma-style": ["off", "first", { 40 | exceptions: { 41 | ArrayExpression: true, 42 | ObjectExpression: true, 43 | } 44 | }], 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /tests/components/WhoAmI.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chai, { expect } from 'chai' 3 | chai.use(require('chai-enzyme')()) 4 | import { shallow } from 'enzyme' 5 | import { spy } from 'sinon' 6 | chai.use(require('sinon-chai')) 7 | import { createStore } from 'redux' 8 | 9 | import WhoAmIContainer, { WhoAmI } from 'APP/app/components/Authentication/WhoAmI' 10 | 11 | /* global describe it beforeEach */ 12 | describe('', () => { 13 | const user = { 14 | name: 'Dr. Bones', 15 | } 16 | const logout = spy() 17 | let root 18 | beforeEach('render the root', () => 19 | root = shallow() 20 | ) 21 | 22 | it('greets the user', () => { 23 | expect(root.text()).to.contain(user.name) 24 | }) 25 | 26 | it('has a logout button', () => { 27 | expect(root.find('button.logout')).to.have.length(1) 28 | }) 29 | 30 | it('calls props.logout when logout is tapped', () => { 31 | root.find('button.logout').simulate('click') 32 | expect(logout).to.have.been.called 33 | }) 34 | }) 35 | 36 | describe("'s connection", () => { 37 | const state = { 38 | auth: {name: 'Dr. Bones'} 39 | } 40 | 41 | let root, store, dispatch 42 | beforeEach('create store and render the root', () => { 43 | store = createStore(state => state, state) 44 | dispatch = spy(store, 'dispatch') 45 | root = shallow() 46 | }) 47 | 48 | it('gets prop.user from state.auth', () => { 49 | expect(root.find(WhoAmI)).to.have.prop('user').eql(state.auth) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /app/reducers/auth.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const initialState = { 4 | authenticatedUser: {} 5 | } 6 | 7 | /* ----------------- ACTION TYPES ------------------ */ 8 | const AUTHENTICATED = 'AUTHENTICATED' 9 | 10 | /* ------------ ACTION CREATORS ------------------ */ 11 | export const authenticated = user => ({ 12 | type: AUTHENTICATED, 13 | user 14 | }) 15 | 16 | /* ------------ REDUCER ------------------ */ 17 | const reducer = ( state = initialState, action ) => { 18 | const newState = Object.assign( {}, state ) 19 | switch ( action.type ) { 20 | case AUTHENTICATED: 21 | newState.authenticatedUser = action.user 22 | break 23 | default: 24 | return state 25 | } 26 | return newState 27 | } 28 | 29 | /* ------------ DISPATCHERS ------------------ */ 30 | export const login = ( username, password ) => 31 | dispatch => 32 | axios.post('/api/auth/login/local', { username, password }) 33 | .then(() => dispatch(whoami())) 34 | .catch(() => dispatch(whoami())) 35 | 36 | export const logout = () => 37 | dispatch => 38 | axios.post('/api/auth/logout') 39 | .then(() => dispatch(whoami())) 40 | .catch(() => dispatch(whoami())) 41 | 42 | export const whoami = () => 43 | dispatch => 44 | axios.get('/api/auth/whoami') 45 | .then(response => { 46 | const user = response.data 47 | dispatch(authenticated( user )) 48 | }) 49 | .catch(failed => dispatch(authenticated(null))) 50 | 51 | /* ------------ EXPORTS ------------------ */ 52 | export default reducer 53 | -------------------------------------------------------------------------------- /app/components/Products/AllProducts.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import { connect } from 'react-redux' 4 | import { Flex, Box } from 'grid-styled' 5 | 6 | import { login } from 'APP/app/reducers/auth' 7 | import styled from 'styled-components' 8 | 9 | import Item from './Item' 10 | 11 | const Div = styled.div`` 12 | // background-color: lightgrey; 13 | // ` 14 | 15 | /* ----------------- COMPONENT ------------------ */ 16 | export class AllProducts extends React.Component { 17 | 18 | constructor(props) { 19 | super(props) 20 | } 21 | 22 | render() { 23 | return ( 24 |
25 | 26 | { 27 | this.props.products && this.props.products.map(product => { 28 | return ( 29 | 34 | 41 | 42 | ) 43 | }) 44 | } 45 | 46 |
47 | ) 48 | } 49 | } 50 | 51 | /* ----------------- CONTAINER ------------------ */ 52 | const mapState = state => ({ products: state.product.products }) 53 | const mapDispatch = null 54 | 55 | export default connect( mapState, mapDispatch )( AllProducts ) 56 | -------------------------------------------------------------------------------- /server/users.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | const User = db.model('users') 5 | 6 | const {mustBeLoggedIn, forbidden} = require('./auth.filters') 7 | 8 | module.exports = require('express').Router() 9 | .get('/', 10 | // The forbidden middleware will fail *all* requests to list users. 11 | // Remove it if you want to allow anyone to list all users on the site. 12 | // 13 | // If you want to only let admins list all the users, then you'll 14 | // have to add a role column to the users table to support 15 | // the concept of admin users. 16 | forbidden('listing users is not allowed'), 17 | (req, res, next) => 18 | User.findAll() 19 | .then(users => res.json(users)) 20 | .catch(next)) 21 | .post('/', 22 | (req, res, next) => 23 | User.create(req.body) 24 | .then(user => res.status(201).json(user)) 25 | .catch(next)) 26 | .get('/:id', 27 | mustBeLoggedIn, 28 | (req, res, next) => 29 | User.findById(req.params.id) 30 | .then(user => res.json(user)) 31 | .catch(next)) 32 | .put('/:id', 33 | mustBeLoggedIn, 34 | (req, res, next) => 35 | User.findById(req.params.id) 36 | .then(user => user.update(req.body)) 37 | .then(updateduser => res.json(updateduser)) 38 | .catch(next)) 39 | .delete('/:id', 40 | mustBeLoggedIn, 41 | (req, res, next) => 42 | User.findById(req.params.id) 43 | .then(user => user.destroy()) 44 | .then(wasDestroyedBool => { 45 | if (wasDestroyedBool) { 46 | res.sendStatus(204) 47 | } else { 48 | const err = Error('user not destroyed') 49 | err.status = 400 50 | throw err 51 | } 52 | }) 53 | .catch(next)) 54 | -------------------------------------------------------------------------------- /bin/build-branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Paths to add to the deployment branch. 4 | # 5 | # These paths will be added with git add -f, to include build artifacts 6 | # we normally ignore in the branch we push to heroku. 7 | build_paths="public" 8 | 9 | # colors 10 | red='\033[0;31m' 11 | blue='\033[0;34m' 12 | off='\033[0m' 13 | 14 | echoed() { 15 | echo "${blue}${*}${off}" 16 | $* 17 | } 18 | 19 | if [[ $(git status --porcelain 2> /dev/null | grep -v '$\?\?' | tail -n1) != "" ]]; then 20 | echo "${red}Uncommitted changes would be lost. Commit or stash these changes:${off}" 21 | git status 22 | exit 1 23 | fi 24 | 25 | # Our branch name is build/commit-sha-hash 26 | version="$(git log -n1 --pretty=format:%H)" 27 | branch_name="build/${version}" 28 | 29 | 30 | function create_build_branch() { 31 | git branch "${branch_name}" 32 | git checkout "${branch_name}" 33 | return 0 34 | } 35 | 36 | function commit_build_artifacts() { 37 | # Add our build paths. -f means "even if it's in .gitignore'". 38 | git add -f "${build_paths}" 39 | 40 | # Commit the build artifacts on the branch. 41 | git commit -a -m "Built ${version} on $(date)." 42 | 43 | # Always succeed. 44 | return 0 45 | } 46 | 47 | # We expect to be sourced by some file that defines a deploy 48 | # function. If deploy() isn't defined, define a stub function. 49 | if [[ -z $(type -t deploy) ]]; then 50 | function deploy() { 51 | echo '(No deployment step defined.)' 52 | return 0 53 | } 54 | fi 55 | 56 | ( 57 | create_build_branch && 58 | echoed yarn && 59 | echoed npm run build && 60 | commit_build_artifacts && 61 | deploy 62 | 63 | # Regardless of whether we succeeded or failed, go back to 64 | # the previous branch. 65 | git checkout - 66 | ) 67 | -------------------------------------------------------------------------------- /tests/components/Login.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chai, { expect } from 'chai' 3 | chai.use(require('chai-enzyme')()) 4 | import { shallow } from 'enzyme' 5 | import { spy } from 'sinon' 6 | chai.use(require('sinon-chai')) 7 | 8 | import { Login } from 'APP/app/components/Authentication/Login' 9 | 10 | /* global describe it beforeEach */ 11 | describe('', () => { 12 | let root 13 | beforeEach('render the root', () => 14 | root = shallow() 15 | ) 16 | 17 | it('shows a login form', () => { 18 | expect(root.find('input[name="username"]')).to.have.length(1) 19 | expect(root.find('input[name="password"]')).to.have.length(1) 20 | }) 21 | 22 | it('shows a password field', () => { 23 | const pw = root.find('input[name="password"]') 24 | expect(pw).to.have.length(1) 25 | expect(pw.at(0)).to.have.attr('type').equals('password') 26 | }) 27 | 28 | it('has a login button', () => { 29 | const submit = root.find('input[type="submit"]') 30 | expect(submit).to.have.length(1) 31 | }) 32 | 33 | describe('when submitted', () => { 34 | const login = spy() 35 | const root = shallow() 36 | const submitEvent = { 37 | preventDefault: spy(), 38 | target: { 39 | username: {value: 'bones@example.com'}, 40 | password: {value: '12345'}, 41 | } 42 | } 43 | 44 | beforeEach('submit', () => { 45 | login.reset() 46 | submitEvent.preventDefault.reset() 47 | root.simulate('submit', submitEvent) 48 | }) 49 | 50 | it('calls props.login with credentials', () => { 51 | expect(login).to.have.been.calledWith( 52 | submitEvent.target.username.value, 53 | submitEvent.target.password.value, 54 | ) 55 | }) 56 | 57 | it('calls preventDefault', () => { 58 | expect(submitEvent.preventDefault).to.have.been.called 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {resolve} = require('path') 4 | , chalk = require('chalk') 5 | , pkg = require('./package.json') 6 | , debug = require('debug')(`${pkg.name}:boot`) 7 | 8 | , nameError = 9 | `******************************************************************* 10 | You need to give your app a proper name. 11 | 12 | The package name 13 | 14 | ${pkg.name} 15 | 16 | isn't valid. If you don't change it, things won't work right. 17 | 18 | Please change it in ${__dirname}/package.json 19 | ~ xoxo, bones 20 | ********************************************************************` 21 | 22 | const reasonableName = /^[a-z0-9\-_]+$/ 23 | // RegExp.test docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test 24 | if (!reasonableName.test(pkg.name)) { 25 | console.error(chalk.red(nameError)) 26 | } 27 | 28 | // This will load a secrets file from 29 | // 30 | // ~/.your_app_name.env.js 31 | // or ~/.your_app_name.env.json 32 | // 33 | // and add it to the environment. 34 | // Note that this needs to be in your home directory, not the project's root directory 35 | const env = process.env 36 | , secretsFile = resolve(require('homedir')(), `.${pkg.name}.env`) 37 | 38 | try { 39 | Object.assign(env, require(secretsFile)) 40 | } catch (error) { 41 | debug('%s: %s', secretsFile, error.message) 42 | debug('%s: env file not found or invalid, moving on', secretsFile) 43 | } 44 | 45 | module.exports = { 46 | get name() { return pkg.name }, 47 | get isTesting() { return !!global.it }, 48 | get isProduction() { 49 | return env.NODE_ENV === 'production' 50 | }, 51 | get isDevelopment() { 52 | return env.NODE_ENV === 'development' 53 | }, 54 | get baseUrl() { 55 | return env.BASE_URL || `http://localhost:${module.exports.port}` 56 | }, 57 | get port() { 58 | return env.PORT || 1337 59 | }, 60 | get root() { 61 | return __dirname 62 | }, 63 | package: pkg, 64 | env, 65 | } 66 | -------------------------------------------------------------------------------- /gitWorkFlow.mdown: -------------------------------------------------------------------------------- 1 | 2 | - Team comes up with issue 3 | - Team assigns person or pair (**A**) to issue 4 | 5 | FOR A NEW ISSUE: 6 | - **A** does `git checkout master` 7 | - **A** does `git pull origin master` to sync up 8 | - **A** does `git checkout -b the-name-of-the-feature-or-whatever` 9 | - **A** does `git push --set-upstream origin the-name-of-the-feature-or-whatever` (DO HERE SO WAFFLE UPDATES) 10 | 11 | MULTIPLE TIME: 12 | - **A** makes changes 13 | - **A** does `git add -A` 14 | - **A** does `git commit -m "Useful message goes here"` 15 | - **A** does `git push origin the-name-of-the-feature-or-whatever` 16 | - Use "imperative mood": `update documentation about dinosaur skull stuff` as opposed to `updated the documentation about dinosaur skull stuff` ([more here](https://chris.beams.io/posts/git-commit/)) 17 | - Use a label upfront, here are some good ones: `fix`, `perf`, `refactor`, `style`, `test`, `feat`, `chore`, `docs` ([more here](https://seesparkbox.com/foundry/semantic_commit_messages)) 18 | 19 | WHEN READY TO MAKE YOUR PULL REQUEST 20 | - Eventually **A** feels the code is ready 21 | - **A** does `git checkout master` 22 | - **A** does `git pull origin master` 23 | - **A** does `git checkout the-name-of-the-feature-or-whatever` 24 | - **A** does `git merge master` to sync with existing code 25 | - **A** does `git push origin the-name-of-the-feature-or-whatever` 26 | - **A** clicks "COMPARE & PULL REQUEST" in Github.com/GraceShopper/LisasBikes 27 | - **A** adds a message "closes #issue-number-from-waffle" and presses "Create Pull Request" 28 | - **A** assigns some other person / people (**B**) to review it 29 | - **B** makes comments, suggests changes 30 | - **A** whines about it, but makes the changes 31 | - Eventually, **B** approves 32 | - **A** hits Merge Pull Request in github 33 | - **A** hits Delete Branch in github 34 | - **A** does `git checkout master` 35 | - **A** does `git branch -d the-name-of-the-feature-or-whatever` to delete the branch locally 36 | -Return to top! 37 | -------------------------------------------------------------------------------- /server/reviews.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | const Review = db.model('reviews') 5 | 6 | const {mustBeLoggedIn, forbidden} = require('./auth.filters') 7 | 8 | module.exports = require('express').Router() 9 | .get('/', 10 | // The forbidden middleware will fail *all* requests to list users. 11 | // Remove it if you want to allow anyone to list all users on the site. 12 | // 13 | // If you want to only let admins list all the users, then you'll 14 | // have to add a role column to the users table to support 15 | // the concept of admin users. 16 | forbidden('listing reviews is not allowed'), 17 | (req, res, next) => 18 | Review.findAll() 19 | .then(reviews => res.json(reviews)) 20 | .catch(next)) 21 | .post('/', 22 | (req, res, next) => 23 | Review.create(req.body) 24 | .then(review => res.status(201).json(review)) 25 | .catch(next)) 26 | .get('/:id', 27 | // TO DO: make sure that this review belongs to user and review 28 | (req, res, next) => 29 | Review.findById(req.params.id) 30 | .then(review => { res.json(review) }) 31 | .catch(next)) 32 | .put('/:id', 33 | // TO DO: make sure that this review belongs to user and review 34 | // must be logged in to edit? 35 | mustBeLoggedIn, 36 | (req, res, next) => 37 | Review.findById(req.params.id) 38 | .then(review => review.update(req.body)) 39 | .then(updatedreview => res.json(updatedreview)) 40 | .catch(next)) 41 | .delete('/:id', 42 | // TO DO: make sure that this user is Admin 43 | mustBeLoggedIn, 44 | (req, res, next) => 45 | Review.findById(req.params.id) 46 | .then(review => review.destroy()) 47 | .then(wasDestroyedBool => { 48 | if (wasDestroyedBool) { 49 | res.sendStatus(204) 50 | } else { 51 | const err = Error('review not destroyed') 52 | err.status = 400 53 | throw err 54 | } 55 | }) 56 | .catch(next)) 57 | -------------------------------------------------------------------------------- /server/orders.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | const Order = db.model('orders') 5 | 6 | const { mustBeLoggedIn, forbidden } = require('./auth.filters') 7 | 8 | module.exports = require('express').Router() 9 | .get('/', 10 | // The forbidden middleware will fail *all* requests to list orders. 11 | // Remove it if you want to allow anyone to list all orders on the site. 12 | // 13 | // If you want to only let admins list all the orders, then you'll 14 | // have to add a role column to the orders table to support 15 | // the concept of admin orders. 16 | // forbidden('listing orders is not allowed'), 17 | (req, res, next) => 18 | Order.findAll() 19 | .then(orders => res.json(orders)) 20 | .catch(next)) 21 | .post('/', 22 | (req, res, next) => 23 | Order.create(req.body) 24 | .then(order => res.status(201).json(order)) 25 | .catch(next)) 26 | .get('/new', 27 | (req, res, next) => { 28 | Order.findOrCreate({ where: { id: req.session.orderId } }) 29 | .spread((order, created) => { 30 | req.session.orderId = order.id 31 | res.json(order) 32 | }) 33 | .catch(next) 34 | }) 35 | .get('/:id', 36 | mustBeLoggedIn, 37 | (req, res, next) => { 38 | Order.findOrCreate({ where: { id: req.params.id } }) 39 | .then(order => res.json(order)) 40 | .catch(next) 41 | }) 42 | .put('/:id', 43 | mustBeLoggedIn, 44 | (req, res, next) => 45 | Order.findById(req.params.id) 46 | .then(order => order.update(req.body)) 47 | .then(updatedOrder => res.json(updatedOrder)) 48 | .catch(next)) 49 | .delete('/:id', 50 | mustBeLoggedIn, 51 | (req, res, next) => 52 | Order.findById(req.params.id) 53 | .then(order => order.destroy()) 54 | .then(wasDestroyedBool => { 55 | if (wasDestroyedBool) { 56 | res.sendStatus(204) 57 | } else { 58 | const err = Error('order not destroyed') 59 | err.status = 400 60 | throw err 61 | } 62 | }) 63 | .catch(next)) 64 | -------------------------------------------------------------------------------- /bin/mkapplink.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | // 'bin/setup' is a symlink pointing to this file, which makes a 6 | // symlink in your project's main node_modules folder that points to 7 | // the root of your project's directory. 8 | 9 | const chalk = require('chalk') 10 | , fs = require('fs') 11 | , {resolve} = require('path') 12 | 13 | , appLink = resolve(__dirname, '..', 'node_modules', 'APP') 14 | 15 | , symlinkError = error => 16 | `******************************************************************* 17 | ${appLink} must point to '..' 18 | 19 | This symlink lets you require('APP/some/path') rather than 20 | ../../../some/path 21 | 22 | I tried to create it, but got this error: 23 | ${error.message} 24 | 25 | You might try this: 26 | 27 | rm ${appLink} 28 | 29 | Then run me again. 30 | 31 | ~ xoxo, bones 32 | ********************************************************************` 33 | 34 | function makeAppSymlink() { 35 | console.log(`Linking '${appLink}' to '..'`) 36 | try { 37 | // fs.unlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_unlinksync_path 38 | try { fs.unlinkSync(appLink) } catch (swallowed) { } 39 | // fs.symlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_symlinksync_target_path_type 40 | const linkType = process.platform === 'win32' ? 'junction' : 'dir' 41 | fs.symlinkSync('..', appLink, linkType) 42 | } catch (error) { 43 | console.error(chalk.red(symlinkError(error))) 44 | // process.exit docs: https://nodejs.org/api/process.html#process_process_exit_code 45 | process.exit(1) 46 | } 47 | console.log(`Ok, created ${appLink}`) 48 | } 49 | 50 | function ensureAppSymlink() { 51 | try { 52 | // readlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_readlinksync_path_options 53 | const currently = fs.readlinkSync(appLink) 54 | if (currently !== '..') { 55 | throw new Error(`${appLink} is pointing to '${currently}' rather than '..'`) 56 | } 57 | } catch (error) { 58 | makeAppSymlink() 59 | } 60 | } 61 | 62 | if (module === require.main) { 63 | ensureAppSymlink() 64 | } 65 | -------------------------------------------------------------------------------- /db/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Require our models. Running each module registers the model into sequelize 4 | // so any other part of the application could call sequelize.model('User') 5 | // to get access to the User model. 6 | 7 | const app = require('APP') 8 | , debug = require('debug')(`${app.name}:models`) 9 | // Our model files export functions that take a database and return 10 | // a model. We call these functions "meta models" (they are models of 11 | // models). 12 | // 13 | // This lets us avoid cyclic dependencies, which can be hard to reason 14 | // about. 15 | , metaModels = { 16 | OAuth: require('./oauth'), 17 | // ---------- Add new models here ---------- 18 | Product: require('./product'), 19 | Review: require('./review'), 20 | Item: require('./item'), 21 | Order: require('./order'), 22 | User: require('./user'), 23 | } 24 | , {mapValues} = require('lodash') 25 | 26 | module.exports = db => { 27 | // Create actual model classes by calling each meta model with the 28 | // database. 29 | const models = mapValues(metaModels, defineModel => defineModel(db)) 30 | 31 | /* 32 | At this point, all our models have been created. We just need to 33 | create the associations between them. 34 | 35 | We pass the responsibility for this onto the models themselves: 36 | If they export an `associations` method, we'll call it, passing 37 | in all the models that have been defined. 38 | 39 | This lets us move the association logic to the model files, 40 | so all the knowledge about the structure of each model remains 41 | self-contained. 42 | 43 | The Sequelize docs suggest a similar setup: 44 | 45 | https://github.com/sequelize/express-example#sequelize-setup 46 | */ 47 | Object.keys(metaModels) 48 | .forEach(name => { 49 | const {associations} = metaModels[name] 50 | if (typeof associations === 'function') { 51 | debug('associating model %s', name) 52 | // Metamodel::associations(self: Model, others: {[name: String]: Model}) -> () 53 | // 54 | // Associate self with others. 55 | associations.call(metaModels[name], models[name], models) 56 | } 57 | }) 58 | 59 | return models 60 | } 61 | -------------------------------------------------------------------------------- /app/components/Navbar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { Link, browserHistory } from 'react-router' 4 | import styled from 'styled-components' 5 | 6 | import Login from '../Authentication/Login' 7 | import WhoAmI from '../Authentication/WhoAmI' 8 | 9 | import SiteName from './SiteName' 10 | import NavLink from './NavLink' 11 | import UserIcon from './UserIcon' 12 | 13 | /* ----------------- STYLED COMPONENTS ------------------ */ 14 | const Div = styled.div` 15 | display: flex; 16 | flex-wrap: nowrap; 17 | justify-content: center; 18 | align-items: center; 19 | color: ${ props => props.theme.text ? props.theme.text : 'white' }; 20 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '50px' }; 21 | margin-left: 3rem; 22 | @media (max-width: 768px) { 23 | justify-content: space-around; 24 | } 25 | ` 26 | 27 | const Nav = styled.nav` 28 | display: flex; 29 | flex-wrap: nowrap; 30 | justify-content: space-between; 31 | background-color: white; 32 | color: ${ props => props.theme.text ? props.theme.text : 'white' }; 33 | min-height: ${ props => props.theme.height ? props.theme.height + 'px' : '.5rem' }; 34 | border-bottom: .1rem solid black; 35 | 36 | @media (max-width: 768px) { 37 | position: relative; 38 | flex-direction: column; 39 | justify-content: center; 40 | align-items: center; 41 | min-width: 200px; 42 | min-height: 100%; 43 | } 44 | ` 45 | 46 | /* ----------------- COMPONENT ------------------ */ 47 | class Navbar extends React.Component { 48 | constructor( props ) { 49 | super( props ) 50 | } 51 | 52 | render() { 53 | return ( 54 | 72 | ) 73 | } 74 | } 75 | 76 | /* ----------------- CONTAINER ------------------ */ 77 | const mapState = ({ auth }) => ({ user: auth }) 78 | const mapDispatch = null 79 | 80 | export default connect( mapState, mapDispatch )( Navbar ) 81 | -------------------------------------------------------------------------------- /dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Concurrently run our various dev tasks. 3 | * 4 | * Usage: node dev 5 | **/ 6 | 7 | const app = require('.') 8 | , chalk = require('chalk'), {bold} = chalk 9 | , {red, green, blue, cyan, yellow} = bold 10 | , dev = module.exports = () => run({ 11 | server: task(app.package.scripts['start-watch'], {color: blue}), 12 | build: task(app.package.scripts['build-watch'], {color: green}), 13 | lint: task(app.package.scripts['lint-watch'], {color: cyan}), 14 | test: task(app.package.scripts['test-watch'], {color: yellow}) 15 | }) 16 | 17 | const taskEnvironment = (path=require('path')) => { 18 | const env = {} 19 | for (const key in process.env) { 20 | env[key] = process.env[key] 21 | } 22 | Object.assign(env, { 23 | NODE_ENV: 'development', 24 | PATH: [ path.join(app.root, 'node_modules', '.bin') 25 | , process.env.PATH ].join(path.delimiter) 26 | }) 27 | return env 28 | } 29 | 30 | function run(tasks) { 31 | Object.keys(tasks) 32 | .map(name => tasks[name](name)) 33 | } 34 | 35 | function task(command, { 36 | spawn=require('child_process').spawn, 37 | path=require('path'), 38 | color 39 | }={}) { 40 | return name => { 41 | const stdout = log({name, color}, process.stdout) 42 | , stderr = log({name, color, text: red}, process.stderr) 43 | , proc = spawn(command, { 44 | shell: true, 45 | stdio: 'pipe', 46 | env: taskEnvironment(), 47 | }).on('error', stderr) 48 | .on('exit', (code, signal) => { 49 | stderr(`Exited with code ${code}`) 50 | if (signal) stderr(`Exited with signal ${signal}`) 51 | }) 52 | proc.stdout.on('data', stdout) 53 | proc.stderr.on('data', stderr) 54 | } 55 | } 56 | 57 | function log({ 58 | name, 59 | ts=timestamp, 60 | color=none, 61 | text=none, 62 | }, out=process.stdout) { 63 | return data => data.toString() 64 | // Strip out screen-clearing control sequences, which really 65 | // muck up the output. 66 | .replace('\u001b[2J', '') 67 | .replace('\u001b[1;3H', '') 68 | .split('\n') 69 | .forEach(line => out.write(`${color(`${ts()} ${name} \t⎹ `)}${text(line)}\n`)) 70 | } 71 | 72 | const dateformat = require('dateformat') 73 | function timestamp() { 74 | return dateformat('yyyy-mm-dd HH:MM:ss (Z)') 75 | } 76 | 77 | function none(x) { return x } 78 | 79 | if (module === require.main) { dev() } 80 | -------------------------------------------------------------------------------- /db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // bcrypt docs: https://www.npmjs.com/package/bcrypt 4 | const bcrypt = require('bcryptjs'), 5 | { STRING, VIRTUAL, BOOLEAN } = require('sequelize') 6 | 7 | module.exports = db => db.define('users', { 8 | // should we separate first and last name like done in tests or not? 9 | first_name: { 10 | type: STRING, 11 | allowNull: false, 12 | validate: { 13 | notEmpty: true, 14 | } 15 | }, 16 | last_name: { 17 | type: STRING, 18 | allowNull: false, 19 | validate: { 20 | notEmpty: true, 21 | } 22 | }, 23 | user_name: { 24 | type: STRING, 25 | allowNull: false, 26 | validate: { 27 | notEmpty: true, 28 | } 29 | }, 30 | email: { 31 | type: STRING, 32 | allowNull: false, 33 | validate: { 34 | isEmail: true, 35 | notEmpty: true, 36 | }, 37 | unique: true, 38 | }, 39 | is_admin: { 40 | type: BOOLEAN, 41 | defaultValue: false, 42 | // we could add our own custom validator for boolean: 43 | // http://stackoverflow.com/questions/36069722/sequelize-datatypes-not-being-validated/36104158 44 | }, 45 | 46 | // We support oauth, so users may or may not have passwords. 47 | password_digest: STRING, // This column stores the hashed password in the DB, via the beforeCreate/beforeUpdate hooks 48 | password: VIRTUAL // Note that this is a virtual, and not actually stored in DB 49 | }, { 50 | indexes: [{ fields: ['email'], unique: true }], 51 | hooks: { 52 | beforeCreate: setEmailAndPassword, 53 | beforeUpdate: setEmailAndPassword, 54 | }, 55 | defaultScope: { 56 | attributes: { exclude: ['password_digest'] } 57 | }, 58 | scopes: { 59 | currentOrder: { 60 | include: [{ 61 | model: db.model('orders'), 62 | where: { status: 'Pending' }, 63 | required: false 64 | }] 65 | } 66 | }, 67 | instanceMethods: { 68 | // This method is a Promisified bcrypt.compare 69 | authenticate(plaintext) { 70 | return bcrypt.compare(plaintext, this.password_digest) 71 | } 72 | } 73 | }) 74 | 75 | module.exports.associations = (User, { OAuth, Review, Order }) => { 76 | User.hasOne(OAuth) 77 | // do we need through tables here? 78 | User.hasMany(Review) 79 | User.hasMany(Order) 80 | } 81 | 82 | function setEmailAndPassword(user) { 83 | user.email = user.email && user.email.toLowerCase() 84 | if (!user.password) return Promise.resolve(user) 85 | 86 | return bcrypt.hash(user.get('password'), 10) 87 | .then(hash => user.set('password_digest', hash)) 88 | } 89 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const app = require('APP') 3 | , debug = require('debug')(`${app.name}:db`) // DEBUG=your_app_name:db 4 | , chalk = require('chalk') 5 | , Sequelize = require('sequelize') 6 | 7 | , name = (app.env.DATABASE_NAME || app.name) + 8 | (app.isTesting ? '_test' : '') 9 | , url = app.env.DATABASE_URL || `postgres://localhost:5432/${name}` 10 | 11 | debug(chalk.yellow(`Opening database connection to ${url}`)) 12 | const db = module.exports = new Sequelize(url, { 13 | logging: require('debug')('sql'), // export DEBUG=sql in the environment to 14 | // get SQL queries 15 | define: { 16 | underscored: true, // use snake_case rather than camelCase column names. 17 | // these are easier to work with in psql. 18 | freezeTableName: true, // don't change table names from the one specified 19 | timestamps: true, // automatically include timestamp columns 20 | } 21 | }) 22 | 23 | // Initialize all our models and assign them as properties 24 | // on the database object. 25 | // 26 | // This lets us use destructuring to get at them like so: 27 | // 28 | // const {User, Product} = require('APP/db') 29 | // 30 | Object.assign(db, require('./models')(db), 31 | // We'll also make createAndSync available. It's sometimes useful in tests. 32 | {createAndSync}) 33 | 34 | // After defining all the models, sync the database. 35 | // Notice that didSync *is* a Promise, rather than being a function that returns 36 | // a Promise. It holds the state of this initial sync. 37 | db.didSync = db.createAndSync() 38 | 39 | // sync the db, creating it if necessary 40 | function createAndSync(force=app.isTesting, retries=0, maxRetries=5) { 41 | return db.sync({force}) 42 | .then(() => debug(`Synced models to db ${url}`)) 43 | .catch(fail => { 44 | // Don't do this auto-create nonsense in prod, or 45 | // if we've retried too many times. 46 | if (app.isProduction || retries > maxRetries) { 47 | console.error(chalk.red(`********** database error ***********`)) 48 | console.error(chalk.red(` Couldn't connect to ${url}`)) 49 | console.error() 50 | console.error(chalk.red(fail)) 51 | console.error(chalk.red(`*************************************`)) 52 | return 53 | } 54 | // Otherwise, do this autocreate nonsense 55 | debug(`${retries ? `[retry ${retries}]` : ''} Creating database ${name}...`) 56 | return new Promise(resolve => 57 | // 'child_process.exec' docs: https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback 58 | require('child_process').exec(`createdb "${name}"`, resolve) 59 | ).then(() => createAndSync(true, retries + 1)) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /tests/server/auth.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | const { expect } = require('chai') 3 | const db = require('APP/db'), 4 | { User } = db 5 | const app = require('APP/server/start') 6 | 7 | const alice = { 8 | username: 'alice@secrets.org', 9 | password: '12345' 10 | } 11 | 12 | /* global describe it before afterEach beforeEach */ 13 | describe('/api/auth', () => { 14 | before('Await database sync', () => db.didSync) 15 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 16 | 17 | beforeEach('create a user', () => 18 | User.create({ 19 | first_name: 'alice', 20 | last_name: 'munro', 21 | user_name: 'alice_munro', 22 | email: alice.username, 23 | password: alice.password 24 | }) 25 | ) 26 | 27 | describe('POST /login/local (username, password)', () => { 28 | it('succeeds with a valid username and password', () => 29 | request(app) 30 | .post('/api/auth/login/local') 31 | .send(alice) 32 | .expect(302) 33 | .expect('Set-Cookie', /session=.*/) 34 | .expect('Location', '/') 35 | ) 36 | 37 | it('fails with an invalid username and password', () => 38 | request(app) 39 | .post('/api/auth/login/local') 40 | .send({ username: alice.username, password: 'wrong' }) 41 | .expect(401) 42 | ) 43 | }) 44 | 45 | describe('GET /whoami', () => { 46 | describe('when not logged in', () => 47 | it('responds with an empty object', () => 48 | request(app).get('/api/auth/whoami') 49 | .expect(200) 50 | .then(res => expect(res.body).to.eql({})) 51 | )) 52 | 53 | describe('when logged in', () => { 54 | // supertest agents persist cookies 55 | const agent = request.agent(app) 56 | 57 | beforeEach('log in', () => agent 58 | .post('/api/auth/login/local') 59 | .send(alice)) 60 | 61 | it('responds with the currently logged in user', () => 62 | agent.get('/api/auth/whoami') 63 | .set('Accept', 'application/json') 64 | .expect(200) 65 | .then(res => expect(res.body).to.contain({ 66 | email: alice.username 67 | })) 68 | ) 69 | }) 70 | }) 71 | 72 | describe('POST /logout', () => 73 | describe('when logged in', () => { 74 | const agent = request.agent(app) 75 | 76 | beforeEach('log in', () => agent 77 | .post('/api/auth/login/local') 78 | .send(alice)) 79 | 80 | it('logs you out and redirects to whoami', () => agent 81 | .post('/api/auth/logout') 82 | .expect(302) 83 | .expect('Location', '/api/auth/whoami') 84 | .then(() => 85 | agent.get('/api/auth/whoami') 86 | .expect(200) 87 | .then(rsp => expect(rsp.body).eql({})) 88 | ) 89 | ) 90 | }) 91 | ) 92 | }) 93 | -------------------------------------------------------------------------------- /app/components/SingleProduct/SingleProduct.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import styled from 'styled-components' 4 | 5 | import { login } from 'APP/app/reducers/auth' 6 | import store from 'App/app/store' 7 | import { addProductToOrder } from 'APP/app/reducers/product' 8 | import { deleteItemFromDatabase, addItemToOrder } from 'APP/app/reducers/order' 9 | 10 | import CartButton from './CartButton' 11 | import formatPrice from 'APP/app/utils/priceFormatter' 12 | import { Flex, Box } from 'grid-styled' 13 | 14 | const H1 = styled.h1` 15 | 16 | ` 17 | 18 | const H3 = styled.h3` 19 | 20 | ` 21 | 22 | const Article = styled.article` 23 | display: flex; 24 | flex-direction: row; 25 | padding-top: 5%; 26 | ` 27 | 28 | const Img = styled.img` 29 | display: flex; 30 | ` 31 | 32 | /*------------------- COMPONENT -----------------*/ 33 | export class SingleProduct extends React.Component { 34 | constructor( props ) { 35 | super( props ) 36 | } 37 | 38 | buttonSelector() { 39 | if(true) { //change this to be "if(inOrder)" 40 | return ( 41 | 46 | ) 47 | } 48 | return ( 49 | 54 | ) 55 | } 56 | 57 | render() { 58 | const product = this.props.singleProduct 59 | const reviews = this.props.reviews 60 | return ( 61 |
62 | 63 | 64 |
65 |

{ product.name }

66 |

{ product.description }

67 |
68 | { formatPrice(product.price) } 69 | { this.buttonSelector() } 70 |
71 |
72 |
73 | 74 | { 75 | product.images && 76 | { 80 | } 81 | 82 |
83 |
84 | ) 85 | } 86 | } 87 | 88 | /* ------------------- CONTAINER ----------------- */ 89 | const mapState = state => ({ 90 | singleProduct: state.product.selectedProduct, 91 | auth: state.auth.authenticatedUser 92 | }) 93 | 94 | const mapDispatch = { 95 | deleteItemFromDatabase, 96 | addProductToOrder 97 | } 98 | 99 | export default connect( mapState, mapDispatch )( SingleProduct ) 100 | 101 | 102 | /* */ 107 | -------------------------------------------------------------------------------- /app/main.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Additional Libraries 4 | import axios from 'axios' 5 | 6 | // React Imports 7 | import React from 'react' 8 | import { Router, Route, IndexRoute, browserHistory } from 'react-router' 9 | import { render } from 'react-dom' 10 | import { connect, Provider } from 'react-redux' 11 | import store from './store' 12 | 13 | // Root Imports 14 | import Root from './components/Root' 15 | 16 | // Home Imports 17 | import Home from './components/Home/Home' 18 | 19 | // Product Imports 20 | import AllProducts, { setProducts } from './components/Products/AllProducts' 21 | import SingleProduct from './components/SingleProduct/SingleProduct' 22 | import { fetchProducts, fetchSingleProduct } from './reducers/product' 23 | 24 | // Cart Imports 25 | import Cart from './components/Cart/Cart' 26 | import { setCurrentOrder, fetchSessionOrder, mergeCurrentOrder } from './reducers/order' 27 | 28 | // Authentication Imports 29 | import Authenticate from './components/Authentication/Authenticate' 30 | import Login from './components/Authentication/Login' 31 | import NotFound from './components/NotFound' 32 | import WhoAmI from './components/Authentication/WhoAmI' 33 | import { whoami } from './reducers/auth' 34 | 35 | const fetchInitialData = (nextRouterState) => { 36 | // Dispatching whoami first ensures user is authenticated. 37 | store.dispatch(whoami()) 38 | .then(() => { 39 | // load the correct data based on the state's auth property 40 | const authenticatedUser = store.getState().auth 41 | if (authenticatedUser.id) { 42 | //if user is loaded to state, merge session order 43 | //with authenticated users order 44 | const sessionOrder = store.getState().order.currentOrder 45 | store.dispatch(mergeCurrentOrder(authenticatedUser.orders[0], sessionOrder)) 46 | } else { 47 | //otherwise, fetch all products and the session order 48 | store.dispatch(fetchProducts()) 49 | store.dispatch(fetchSessionOrder()) 50 | } 51 | }) 52 | } 53 | 54 | const onProductEnter = nextRouterState => { 55 | const productId = nextRouterState.params.id 56 | store.dispatch( fetchSingleProduct( productId ) ) 57 | } 58 | 59 | const fetchAllProducts = () => { 60 | store.dispatch(fetchProducts()) 61 | } 62 | 63 | render( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | , 76 | document.getElementById('main'), 77 | null 78 | ) 79 | -------------------------------------------------------------------------------- /tests/db/item.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db'), 4 | { Item } = db, 5 | { expect } = require('chai'), 6 | Promise = require('bluebird') 7 | 8 | describe('The `Item` model', () => { 9 | /** 10 | * First we clear the database and recreate the tables before beginning a run 11 | */ 12 | before('Await database sync', () => db.didSync) 13 | 14 | /** 15 | * Next, we create an (un-saved!) item instance before every spec 16 | */ 17 | let item 18 | beforeEach(() => { 19 | item = Item.build({ 20 | price: 100001, 21 | quantity: 3 22 | }) 23 | }) 24 | 25 | /** 26 | * Also, we empty the tables after each spec 27 | */ 28 | afterEach(() => { 29 | return Promise.all([ 30 | Item.truncate({ cascade: true }) 31 | ]) 32 | }) 33 | 34 | describe('attributes definition', function() { 35 | 36 | it('included `price` and `quantity` fields', function() { 37 | return item.save() 38 | .then(function(savedItem) { 39 | expect(savedItem.price).to.equal(100001) 40 | expect(savedItem.quantity).to.equal(3) 41 | }) 42 | }) 43 | }) 44 | 45 | describe('Validations', () => { 46 | 47 | it('requires `price`', () => { 48 | item.price = null 49 | 50 | return item.validate() 51 | .then(function(result) { 52 | expect(result).to.be.an.instanceOf(Error) 53 | expect(result.message).to.contain('notNull Violation') 54 | }) 55 | }) 56 | 57 | it('errors if `price` is less than zero', () => { 58 | item.price = -20 59 | 60 | return item.validate() 61 | .then(function(result) { 62 | expect(result).to.be.an.instanceOf(Error) 63 | expect(result.message).to.contain('Validation error') 64 | }) 65 | }) 66 | 67 | it('requires `quantity` ', () => { 68 | item.quantity = null 69 | return item.validate() 70 | .then(function(result) { 71 | expect(result).to.be.an.instanceOf(Error) 72 | expect(result.message).to.contain('notNull Violation') 73 | }) 74 | }) 75 | 76 | it('errors if `quantity` is less than zero', () => { 77 | item.quantity = -2 78 | return item.validate() 79 | .then(function(result) { 80 | expect(result).to.be.an.instanceOf(Error) 81 | expect(result.message).to.contain('Validation error') 82 | }) 83 | }) 84 | 85 | it('errors if `quantity` is a decimal', () => { 86 | item.quantity = 2.2 87 | return item.validate() 88 | .then(function(result) { 89 | expect(result).to.be.an.instanceOf(Error) 90 | expect(result.message).to.contain('Validation error') 91 | }) 92 | }) 93 | 94 | it('errors if `quantity` is a string', () => { 95 | item.quantity = 'test' 96 | return item.validate() 97 | .then(function(result) { 98 | expect(result).to.be.an.instanceOf(Error) 99 | expect(result.message).to.contain('Validation error') 100 | }) 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /tests/components/Cart.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { expect } from 'chai' 3 | import { shallow } from 'enzyme' 4 | 5 | import { Cart } from 'APP/app/components/Cart/Cart' 6 | import Item from 'APP/app/components/Products/Item' 7 | 8 | describe.only('', () => { 9 | 10 | describe('visual content', function() { 11 | 12 | let currentOrder, orderItem, orderWrapper 13 | beforeEach('Create wrapper', () => { 14 | currentOrder = { 15 | id: 1, 16 | status: 'Pending', 17 | user_id: 2, 18 | items: [{ 19 | id: 3, 20 | price: 160051, 21 | quantity: 1, 22 | order_id: 1, 23 | product_id: 3, 24 | product: { 25 | id: 3, 26 | name: 'Mount-Pain X-FIRE', 27 | category: 'Mountain', 28 | price: 210052, 29 | images: [ 30 | 'http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-blk-2100.jpg' 31 | ], 32 | color: [ 33 | 'Red', 34 | 'Blue' 35 | ], 36 | size: [ 37 | 'Large', 38 | 'Medium', 39 | 'Small' 40 | ], 41 | quantity: 2403, 42 | reviewStars: '3.2', 43 | description: 'SUCH PAIN AHHH! MEDIUM IS ON THE SMALL SIDE OF THINGS! us vestibulum sagittis sapien cum sociis natoque penatibus et magnis dis parturient montes nascetur ridiculus mus etiam vel augue vestibulum rutrum rutrum neque aenean auctor gravida sem praesent id massa id nisl venenatis lacinia aenean sit amet justo morbi ut odio cras mi pede malesuada in', 44 | } 45 | }] 46 | } 47 | orderItem = { 48 | id: 3, 49 | price: 160051, 50 | quantity: 1, 51 | order_id: 1, 52 | product_id: 3, 53 | product: { 54 | id: 3, 55 | name: 'Mount-Pain X-FIRE', 56 | category: 'Mountain', 57 | price: 210052, 58 | images: [ 59 | 'http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-blk-2100.jpg' 60 | ], 61 | color: [ 62 | 'Red', 63 | 'Blue' 64 | ], 65 | size: [ 66 | 'Large', 67 | 'Medium', 68 | 'Small' 69 | ], 70 | quantity: 2403, 71 | reviewStars: '3.2', 72 | description: 'SUCH PAIN AHHH! MEDIUM IS ON THE SMALL SIDE OF THINGS! us vestibulum sagittis sapien cum sociis natoque penatibus et magnis dis parturient montes nascetur ridiculus mus etiam vel augue vestibulum rutrum rutrum neque aenean auctor gravida sem praesent id massa id nisl venenatis lacinia aenean sit amet justo morbi ut odio cras mi pede malesuada in', 73 | } 74 | } 75 | orderWrapper = shallow() 76 | }) 77 | 78 | it('renders table head with five ths', () => { 79 | const headers = orderWrapper.find('th') 80 | expect(headers).to.have.length(5) 81 | }) 82 | 83 | it('renders an Element', () => { 84 | expect(orderWrapper.containsMatchingElement()).to.equal(true) 85 | }) 86 | 87 | }) 88 | describe('interactivity', function() { 89 | 90 | }) 91 | 92 | }) 93 | -------------------------------------------------------------------------------- /app/components/Cart/Cart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { Link, browserHistory } from 'react-router' 4 | 5 | import styled from 'styled-components' 6 | 7 | // Components 8 | import Login from '../Authentication/Login' 9 | import WhoAmI from '../Authentication/WhoAmI' 10 | import Item from '../Products/Item' 11 | import CheckoutButton from './CheckoutButton' 12 | 13 | // Reducers 14 | import { deleteItemFromDatabase } from 'APP/app/reducers/order' 15 | 16 | import CartItem from './CartItem' 17 | 18 | const Table = styled.table` 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | align-items: center; 23 | ` 24 | 25 | const THead = styled.thead` 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | ` 30 | 31 | const TFoot = styled.tfoot` 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | ` 36 | 37 | /* ----------------- COMPONENT ------------------ */ 38 | export class Cart extends React.Component { 39 | constructor(props) { 40 | super(props) 41 | } 42 | 43 | calculateTotal() { 44 | return this.props.currentOrder.items ? 45 | this.props.currentOrder.items.reduce( 46 | (total, item) => (total + item.price * item.quantity), 0 47 | ) / 100 48 | : 0 49 | } 50 | 51 | render() { 52 | return ( 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | { 66 | this.props.currentOrder.items && 67 | this.props.currentOrder.items.map(item => ( 68 | 69 | 73 | 74 | ) 75 | ) 76 | } 77 | 78 | 79 | 80 | 81 | 82 | 83 | 89 | } /> 90 | 91 | 92 |
93 |
94 |
95 | ) 96 | } 97 | } 98 | 99 | /* ----------------- CONTAINER ------------------ */ 100 | 101 | const mapProps = ({ auth, order }) => ({ user: auth, currentOrder: order.currentOrder }) 102 | 103 | const mapDispatch = dispatch => ({ handleRemove: itemId => { 104 | dispatch(deleteItemFromDatabase(itemId)) 105 | }}) 106 | 107 | export default connect(mapProps, mapDispatch)(Cart) 108 | -------------------------------------------------------------------------------- /app/reducers/product.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const initialState = { 4 | products: [], 5 | selectedProduct: {} 6 | } 7 | 8 | /* ----------------- ACTION TYPES ------------------ */ 9 | const SET_PRODUCTS = 'SET_PRODUCTS' 10 | const SET_SELECTED_PRODUCT = 'SET_SELECTED_PRODUCT' 11 | const DELETE_PRODUCT = 'DELETE_PRODUCT' 12 | const UPDATE_PRODUCT = 'UPDATE_PRODUCT' 13 | const CREATE_PRODUCT = 'CREATE_PRODUCT' 14 | const ADD_PRODUCT_TO_ORDER = 'ADD_PRODUCT_TO_ORDER' 15 | const UPDATE_PRODUCT_IN_ORDER = 'UPDATE_PRODUCT_IN_ORDER' 16 | const REMOVE_PRODUCT_FROM_ORDER = 'REMOVE_PRODUCT_FROM_ORDER' 17 | 18 | /* ------------ ACTION CREATORS ------------------ */ 19 | 20 | export const setProducts = products => ({ 21 | type: SET_PRODUCTS, 22 | products 23 | }) 24 | 25 | export const setProduct = product => ({ 26 | type: SET_SELECTED_PRODUCT, 27 | selectedProduct: product 28 | }) 29 | 30 | export const createProduct = product => ({ 31 | type: CREATE_PRODUCT, 32 | product 33 | }) 34 | 35 | export const updateProduct = product => ({ 36 | type: UPDATE_PRODUCT, 37 | product 38 | }) 39 | 40 | export const deleteProduct = productId => ({ 41 | type: DELETE_PRODUCT, 42 | productId 43 | }) 44 | 45 | export const addProductToOrder = product => ({ 46 | type: ADD_PRODUCT_TO_ORDER, 47 | product 48 | }) 49 | 50 | export const removeProductFromOrder = product => ({ 51 | type: REMOVE_PRODUCT_FROM_ORDER, 52 | product 53 | }) 54 | 55 | export const updateProductInOrder = product => ({ 56 | type: UPDATE_PRODUCT_IN_ORDER, 57 | product 58 | }) 59 | 60 | /* ------------ REDUCERS ------------------ */ 61 | export default function(state = initialState, action) { 62 | const newState = Object.assign({}, state) 63 | 64 | switch ( action.type ) { 65 | case SET_PRODUCTS: 66 | newState.products = action.products 67 | break 68 | case SET_SELECTED_PRODUCT: 69 | newState.selectedProduct = action.selectedProduct 70 | break 71 | case CREATE_PRODUCT: 72 | newState.products = newState.products.concat([action.product]) 73 | break 74 | case UPDATE_PRODUCT: 75 | newState.products = newState.products.map((product) => ( 76 | (product.id === action.product.id) ? action.product : product 77 | )) 78 | break 79 | case DELETE_PRODUCT: 80 | newState.products = newState.products.filter((currentProduct) => ( 81 | (currentProduct.id !== action.productId) 82 | )) 83 | break 84 | default: 85 | return state 86 | } 87 | 88 | return newState 89 | } 90 | /* ------------ DISPATCHERS ------------------ */ 91 | 92 | export const fetchProducts = () => dispatch => { 93 | axios.get('/api/products') 94 | .then(res => dispatch(setProducts(res.data))) 95 | .catch(err => console.error(`Fetching products: unsuccessful`, err)) 96 | } 97 | 98 | export const removeProduct = id => dispatch => { 99 | dispatch(deleteProduct(id)) 100 | axios.delete(`/api/products/:id`) 101 | .catch(err => console.error(`Removing product: unsuccessful`, err)) 102 | } 103 | 104 | export const fetchSingleProduct = id => dispatch => { 105 | axios.get(`/api/products/${id}`) 106 | .then(res => dispatch(setProduct(res.data))) 107 | .catch(err => console.error(`Fetching product failed..`, err)) 108 | } 109 | -------------------------------------------------------------------------------- /db/models/oauth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const app = require('APP') 4 | , debug = require('debug')(`${app.name}:oauth`) 5 | , {STRING, JSON} = require('sequelize') 6 | 7 | module.exports = db => { 8 | const OAuth = db.define('oauths', { 9 | uid: STRING, 10 | provider: STRING, 11 | 12 | // OAuth v2 fields 13 | accessToken: STRING, 14 | refreshToken: STRING, 15 | 16 | // OAuth v1 fields 17 | token: STRING, 18 | tokenSecret: STRING, 19 | 20 | // The whole profile as JSON 21 | profileJson: JSON, 22 | }, { 23 | // Further reading on indexes: 24 | // 1. Sequelize and indexes: http://docs.sequelizejs.com/en/2.0/docs/models-definition/#indexes 25 | // 2. Postgres documentation: https://www.postgresql.org/docs/9.1/static/indexes.html 26 | indexes: [{fields: ['uid'], unique: true}], 27 | }) 28 | 29 | // OAuth.V2 is a default argument for the OAuth.setupStrategy method - it's our callback function that will execute when the user has successfully logged in 30 | OAuth.V2 = (accessToken, refreshToken, profile, done) => 31 | OAuth.findOrCreate({ 32 | where: { 33 | provider: profile.provider, 34 | uid: profile.id, 35 | } 36 | }) 37 | .spread(oauth => { 38 | debug(profile) 39 | debug('provider:%s will log in user:{name=%s uid=%s}', 40 | profile.provider, 41 | profile.displayName, 42 | profile.id 43 | ) 44 | oauth.profileJson = profile 45 | oauth.accessToken = accessToken 46 | 47 | // db.Promise.props is a Bluebird.js method; basically like "all" but for an object whose properties might contain promises. 48 | // Docs: http://bluebirdjs.com/docs/api/promise.props.html 49 | return db.Promise.props({ 50 | oauth, 51 | user: oauth.getUser(), 52 | _saveProfile: oauth.save(), 53 | }) 54 | }) 55 | .then(({ oauth, user }) => user || 56 | OAuth.User.create({ 57 | name: profile.displayName, 58 | }) 59 | .then(user => db.Promise.props({ 60 | user, 61 | _setOauthUser: oauth.setUser(user) 62 | })) 63 | .then(({user}) => user) 64 | ) 65 | .then(user => done(null, user)) 66 | .catch(done) 67 | 68 | // setupStrategy is a wrapper around passport.use, and is called in authentication routes in server/auth.js 69 | OAuth.setupStrategy = 70 | ({ 71 | provider, 72 | strategy, 73 | config, 74 | oauth=OAuth.V2, 75 | passport 76 | }) => { 77 | const undefinedKeys = Object.keys(config) 78 | .map(k => config[k]) 79 | .filter(value => typeof value === 'undefined') 80 | if (undefinedKeys.length) { 81 | for (const key in config) { 82 | if (!config[key]) debug('provider:%s: needs environment var %s', provider, key) 83 | } 84 | debug('provider:%s will not initialize', provider) 85 | return 86 | } 87 | 88 | debug('initializing provider:%s', provider) 89 | 90 | passport.use(new strategy(config, oauth)) 91 | } 92 | 93 | return OAuth 94 | } 95 | 96 | module.exports.associations = (OAuth, {User}) => { 97 | // Create a static association between the OAuth and User models. 98 | // This lets us refer to OAuth.User above, when we need to create 99 | // a user. 100 | OAuth.User = User 101 | OAuth.belongsTo(User) 102 | } 103 | -------------------------------------------------------------------------------- /app/reducers/user.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const initialState = { 4 | users: [], 5 | selectedUser: {} 6 | } 7 | 8 | /* ----------------- ACTION TYPES ------------------ */ 9 | const SET_USERS = 'SET_USERS' 10 | const SET_SELECTED_USERS = 'SET_SELECTED_USERS' 11 | const DELETE_USER = 'DELETE_USER' 12 | const UPDATE_USER = 'UPDATE_USER' 13 | const CREATE_USER = 'CREATE_USER' 14 | const ADD_ORDER_TO_USER = 'ADD_ORDER_TO_USER' 15 | const UPDATE_ORDER_IN_USER = 'UPDATE_ORDER_IN_USER' 16 | const REMOVE_ORDER_FROM_USER = 'REMOVE_ORDER_FROM_USER' 17 | 18 | /* ------------ ACTION CREATORS ------------------ */ 19 | export const setUsers = users => ({ 20 | type: SET_USERS, 21 | users: users 22 | }) 23 | 24 | export const setSelectedUser = user => ({ 25 | type: SET_SELECTED_USER, 26 | selectedUser: user 27 | }) 28 | 29 | export const createUser = user => ({ 30 | type: CREATE_USER, 31 | user: user 32 | }) 33 | 34 | export const updateUser = user => ({ 35 | type: UPDATE_USER, 36 | user: user 37 | }) 38 | 39 | export const deleteUser = userId => ({ 40 | type: DELETE_USER, 41 | userId: userId 42 | }) 43 | 44 | export const addUserToOrder = user => ({ 45 | type: ADD_ORDER_TO_USER, 46 | user: user 47 | }) 48 | 49 | export const removeOrderFromUser = order => ({ 50 | type: REMOVE_ORDER_FROM_USER, 51 | order: order 52 | }) 53 | 54 | export const updateOrderInUser = order => ({ 55 | type: UPDATE_ORDER_IN_USER, 56 | order: order 57 | }) 58 | 59 | 60 | /* ------------ REDUCERS ------------------ */ 61 | export default function(state = initialState, action) { 62 | const newState = Object.assign({}, state) 63 | switch (action.type) { 64 | case SET_USERS: 65 | newState.users = action.users 66 | break 67 | case SET_SELECTED_USERS: 68 | newState.selectedUser = action.selectedUser 69 | break 70 | case CREATE_USER: 71 | newState.users = newState.users.concat([action.users]) 72 | break 73 | case UPDATE_USER: 74 | newState.users = newState.users.map((user) => ( 75 | (user.id === action.user.id) ? action.user : user 76 | )) 77 | break 78 | case DELETE_USER: 79 | newState.users = newState.users.filter((currentUser) => ( 80 | (currentUser.id !== action.userId) 81 | )) 82 | break 83 | case ADD_ORDER_TO_USER: 84 | newState.selectedUser.students = newState.selectedUser.students.concat([action.student]) 85 | break 86 | case REMOVE_ORDER_FROM_USER: 87 | newState.selectedUser.students = 88 | newState.selectedUser.students.filter((student) => (student.id !== action.student.id)) 89 | break 90 | case UPDATE_ORDER_IN_USER: 91 | newState.selectedUser.students = 92 | newState.selectedUser.students.filter((student) => (student.id !== action.student.id)) 93 | break 94 | default: 95 | return state 96 | } 97 | 98 | return newState 99 | } 100 | 101 | /* ------------ DISPATCHERS ------------------ */ 102 | export const fetchUsers = () => dispatch => { 103 | axios.get('/api/users') 104 | .then(res => dispatch(setUsers(res.data))) 105 | } 106 | 107 | export const removeUser = id => dispatch => { 108 | dispatch(deleteUser(id)) 109 | axios.delete(`/api/users/:id`) 110 | .catch(err => console.error(`Removing user: unsuccesful`, err)) 111 | } 112 | -------------------------------------------------------------------------------- /tests/db/user.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | , {User} = db 5 | , {expect} = require('chai') 6 | 7 | /* global describe it before afterEach */ 8 | 9 | describe('The `User` model', () => { 10 | before('Await database sync', () => db.didSync) 11 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 12 | 13 | let user 14 | 15 | beforeEach(function(){ 16 | user = User.build({ 17 | first_name: 'Simon', 18 | last_name: 'Cat', 19 | user_name: 'simoncat', 20 | email: 'scat@gmail.com', 21 | password: 'sssccc', 22 | is_admin: false 23 | }) 24 | }) 25 | 26 | describe('includes all correct attributes,', () => { 27 | it('has correct values', () => { 28 | return user.save() 29 | .then( (savedUser) => { 30 | expect(savedUser.first_name).to.equal('Simon') 31 | expect(savedUser.last_name).to.equal('Cat') 32 | expect(savedUser.user_name).to.equal('simoncat') 33 | expect(savedUser.email).to.equal('scat@gmail.com') 34 | expect(savedUser.password).to.equal('sssccc') 35 | expect(savedUser.is_admin).to.equal(false) 36 | }) 37 | }) 38 | }) 39 | 40 | describe('Validations ', () => { 41 | it('errors when no first name is entered', function () { 42 | user.first_name = null 43 | 44 | return user.validate() 45 | .then(function (result) { 46 | expect(result).to.be.an.instanceOf(Error) 47 | expect(result.message).to.contain('notNull Violation') 48 | }) 49 | }) 50 | 51 | it('requires `first name` to not be an empty string', function () { 52 | 53 | user.first_name = '' 54 | 55 | return user.validate() 56 | .then(function (result) { 57 | expect(result).to.be.an.instanceOf(Error) 58 | expect(result.message).to.contain('Validation error') 59 | }) 60 | }) 61 | 62 | it('errors when no last name is entered', function () { 63 | user.last_name = null 64 | 65 | return user.validate() 66 | .then(function (result) { 67 | expect(result).to.be.an.instanceOf(Error) 68 | expect(result.message).to.contain('notNull Violation') 69 | }) 70 | }) 71 | 72 | it('requires `last name` to not be an empty string', function () { 73 | 74 | user.last_name = '' 75 | 76 | return user.validate() 77 | .then(function (result) { 78 | expect(result).to.be.an.instanceOf(Error) 79 | expect(result.message).to.contain('Validation error') 80 | }) 81 | }) 82 | 83 | 84 | it('errors when no user name is entered', function() { 85 | user.user_name = null 86 | 87 | return user.validate() 88 | .then(function (result) { 89 | expect(result).to.be.an.instanceOf(Error) 90 | expect(result.message).to.contain('notNull Violation') 91 | }) 92 | }) 93 | 94 | it('errors when no email is entered', function() { 95 | user.email = null 96 | 97 | return user.validate() 98 | .then(function (result) { 99 | expect(result).to.be.an.instanceOf(Error) 100 | expect(result.message).to.contain('notNull Violation') 101 | }) 102 | }) 103 | 104 | it('errors when email format is invalid', function () { 105 | user.email = 'clearly_not_an_email' 106 | 107 | return user.validate() 108 | .then(function (result) { 109 | expect(result).to.be.an.instanceOf(Error) 110 | expect(result.message).to.contain('Validation error') 111 | }) 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /app/reducers/order.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const initialState = { 4 | pastOrders: [], 5 | currentOrder: {} 6 | } 7 | 8 | /* ----------------- ACTION TYPES ------------------ */ 9 | const SET_CURRENT_ORDER = 'SET_CURRENT_ORDER' 10 | const SET_PAST_ORDERS = 'SET_PAST_ORDERS' 11 | const UPDATE_ORDER = 'UPDATE_ORDER' 12 | const DELETE_ITEM_FROM_ORDER = 'DELETE_ITEM_FROM_ORDER' 13 | 14 | /* ------------ ACTION CREATORS ------------------ */ 15 | export const setCurrentOrder = order => ({ 16 | type: SET_CURRENT_ORDER, 17 | order 18 | }) 19 | 20 | export const setPastOrders = orders => ({ 21 | type: SET_PAST_ORDERS, 22 | pastOrders: orders 23 | }) 24 | 25 | export const updateOrder = order => ({ 26 | type: UPDATE_ORDER, 27 | order 28 | }) 29 | 30 | export const deleteItemFromOrder = itemId => ({ 31 | type: DELETE_ITEM_FROM_ORDER, 32 | itemId 33 | }) 34 | 35 | /* ------------ REDUCER ------------------ */ 36 | export default function( state = initialState, action ) { 37 | const newState = Object.assign({}, state) 38 | switch (action.type) { 39 | case SET_CURRENT_ORDER: 40 | newState.currentOrder = action.order 41 | break 42 | case SET_PAST_ORDERS: 43 | newState.pastOrders = action.pastOrders 44 | break 45 | case UPDATE_ORDER: 46 | newState.currentOrder = action.order 47 | break 48 | case DELETE_ITEM_FROM_ORDER: 49 | newState.currentOrder = _removeItemFromOrder(action.itemId, state.currentOrder) 50 | break 51 | default: 52 | return state 53 | } 54 | return newState 55 | } 56 | 57 | 58 | export const fetchPastOrders = () => dispatch => { 59 | return axios.get('/api/orders') 60 | .then(res => dispatch(setPastOrders( res.data ))) 61 | .catch(err => console.error(`Fetching past orders unsuccesful`, err)) 62 | } 63 | 64 | export const fetchSessionOrder = () => dispatch => { 65 | return axios.get('/api/orders/new') 66 | .then(res => dispatch(setCurrentOrder( res.data ))) 67 | .catch(err => console.error(`Fetching new order unsuccesful`, err)) 68 | } 69 | 70 | export const updateCurrentOrder = (id, order) => dispatch => { 71 | return axios.put(`/api/orders/${id}`, order) 72 | .then(res => dispatch(updateOrder( res.data ))) 73 | .catch(err => console.error(`Updating order #${id} unsuccessful`, err)) 74 | } 75 | 76 | export const mergeCurrentOrder = ( databaseOrder, sessionOrder ) => dispatch => { 77 | dispatch(setCurrentOrder(_naiveMergeOrders(databaseOrder, sessionOrder))) 78 | } 79 | 80 | export const deleteItemFromDatabase = itemId => dispatch => { 81 | return axios.delete(`/api/items/${itemId}`) 82 | .then(res => { 83 | dispatch(deleteItemFromOrder( itemId )) 84 | }) 85 | .catch(err => console.error(`deleting item id #${itemId} unsuccessful`, err)) 86 | } 87 | /* ------------ HELPER FUNCTIONS ------------------ */ 88 | 89 | /* Naively merge orders with the follow strategy: 90 | * 1) Include everything from primary 91 | * 2) Add all items from secondary to primary 92 | */ 93 | export const _naiveMergeOrders = (databaseOrder = [], sessionOrder) => { 94 | if (!sessionOrder.items) { 95 | return databaseOrder 96 | } 97 | const mergedItems = databaseOrder.items.concat(sessionOrder.items) 98 | return Object.assign({}, databaseOrder, { items: mergedItems }) 99 | } 100 | 101 | const _removeItemFromOrder = (itemId, order) => { 102 | const filteredItems = order.items.filter(item => { 103 | return item.id !== itemId 104 | }) 105 | 106 | return Object.assign({}, order, {items: filteredItems}) 107 | } 108 | -------------------------------------------------------------------------------- /server/start.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const express = require('express') 5 | const bodyParser = require('body-parser') 6 | const {resolve} = require('path') 7 | const passport = require('passport') 8 | const PrettyError = require('pretty-error') 9 | const finalHandler = require('finalhandler') 10 | // PrettyError docs: https://www.npmjs.com/package/pretty-error 11 | 12 | // requires our root index.js: 13 | const pkg = require('APP') 14 | 15 | const app = express() 16 | 17 | if (!pkg.isProduction && !pkg.isTesting) { 18 | // Logging middleware (dev only) 19 | app.use(require('volleyball')) 20 | } 21 | 22 | // Pretty error prints errors all pretty. 23 | const prettyError = new PrettyError 24 | 25 | // Skip events.js and http.js and similar core node files. 26 | prettyError.skipNodeFiles() 27 | 28 | // Skip all the trace lines about express' core and sub-modules. 29 | prettyError.skipPackage('express') 30 | 31 | module.exports = app 32 | // Session middleware - compared to express-session (which is what's used in the Auther workshop), cookie-session stores sessions in a cookie, rather than some other type of session store. 33 | // Cookie-session docs: https://www.npmjs.com/package/cookie-session 34 | .use(require('cookie-session')({ 35 | name: 'session', 36 | keys: [process.env.SESSION_SECRET || 'an insecure secret key'], 37 | })) 38 | 39 | // Body parsing middleware 40 | .use(bodyParser.urlencoded({ extended: true })) 41 | .use(bodyParser.json()) 42 | 43 | // Authentication middleware 44 | .use(passport.initialize()) 45 | .use(passport.session()) 46 | 47 | // Serve static files from ../public 48 | .use(express.static(resolve(__dirname, '..', 'public'))) 49 | .use('/font-awesome', express.static(resolve(__dirname, '..', 'node_modules', 'font-awesome'))) 50 | 51 | // Serve our api - ./api also requires in ../db, which syncs with our database 52 | .use('/api', require('./api')) 53 | 54 | // any requests with an extension (.js, .css, etc.) turn into 404 55 | .use((req, res, next) => { 56 | if (path.extname(req.path).length) { 57 | const err = new Error('Not found') 58 | err.status = 404 59 | next(err) 60 | } else { 61 | next() 62 | } 63 | }) 64 | 65 | // Send index.html for anything else. 66 | .get('/*', (_, res) => res.sendFile(resolve(__dirname, '..', 'public', 'index.html'))) 67 | 68 | // Error middleware interceptor, delegates to same handler Express uses. 69 | // https://github.com/expressjs/express/blob/master/lib/application.js#L162 70 | // https://github.com/pillarjs/finalhandler/blob/master/index.js#L172 71 | .use((err, req, res, next) => { 72 | console.error(prettyError.render(err)) 73 | finalHandler(req, res)(err) 74 | }) 75 | 76 | if (module === require.main) { 77 | // Start listening only if we're the main module. 78 | // 79 | // https://nodejs.org/api/modules.html#modules_accessing_the_main_module 80 | const server = app.listen( 81 | pkg.port, 82 | () => { 83 | console.log(`--- Started HTTP Server for ${pkg.name} ---`) 84 | const { address, port } = server.address() 85 | const host = address === '::' ? 'localhost' : address 86 | const urlSafeHost = host.includes(':') ? `[${host}]` : host 87 | console.log(`Listening on http://${urlSafeHost}:${port}`) 88 | } 89 | ) 90 | } 91 | 92 | // This check on line 64 is only starting the server if this file is being run directly by Node, and not required by another file. 93 | // Bones does this for testing reasons. If we're running our app in development or production, we've run it directly from Node using 'npm start'. 94 | // If we're testing, then we don't actually want to start the server; 'module === require.main' will luckily be false in that case, because we would be requiring in this file in our tests rather than running it directly. 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "climb-shopper", 3 | "version": "0.0.1", 4 | "description": "A happy little skeleton.", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">= 7.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "node dev", 11 | "validate": "check-node-version --node '>= 7.0.0'", 12 | "setup": "./bin/setup", 13 | "prep": "npm run validate && npm run setup", 14 | "postinstall": "npm run prep", 15 | "build": "webpack", 16 | "build-watch": "npm run build -- -w", 17 | "build-dev": "cross-env NODE_ENV=development npm run build-watch", 18 | "build-branch": "bin/build-branch.sh", 19 | "start": "node server/start.js", 20 | "start-watch": "nodemon server/start.js --watch server --watch db --watch index.js --watch package.json", 21 | "start-dev": "cross-env NODE_ENV=development npm run start-watch", 22 | "test": "mocha --compilers js:babel-register --watch-extensions js,jsx tests/**/*.test.js tests/**/*.test.jsx", 23 | "test-watch": "npm run test -- --watch --reporter=min", 24 | "seed": "node db/seed.js", 25 | "deploy-heroku": "bin/deploy-heroku.sh", 26 | "lint": "esw . --ignore-path .gitignore --ext '.js,.jsx'", 27 | "lint-watch": "npm run lint -- -w" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/queerviolet/bones.git" 32 | }, 33 | "keywords": [ 34 | "react", 35 | "redux", 36 | "skeleton" 37 | ], 38 | "author": "Ashi Krishnan ", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/queerviolet/bones/issues" 42 | }, 43 | "homepage": "https://github.com/queerviolet/bones#readme", 44 | "dependencies": { 45 | "axios": "^0.15.2", 46 | "babel-preset-stage-2": "^6.18.0", 47 | "bcryptjs": "^2.4.0", 48 | "body-parser": "^1.15.2", 49 | "chai-enzyme": "^0.5.2", 50 | "chalk": "^1.1.3", 51 | "check-node-version": "^1.1.2", 52 | "concurrently": "^3.1.0", 53 | "cookie-session": "^2.0.0-alpha.1", 54 | "enzyme": "^2.5.1", 55 | "express": "^4.14.0", 56 | "finalhandler": "^1.0.0", 57 | "font-awesome": "^4.7.0", 58 | "grid-styled": "^2.0.0-10", 59 | "homedir": "^0.6.0", 60 | "materialize-css": "^0.98.2", 61 | "passport": "^0.3.2", 62 | "passport-facebook": "^2.1.1", 63 | "passport-github2": "^0.1.10", 64 | "passport-google-oauth": "^1.0.0", 65 | "passport-local": "^1.0.0", 66 | "pg": "^6.1.0", 67 | "pretty-error": "^2.0.2", 68 | "react": "^15.3.2", 69 | "react-dom": "^15.3.2", 70 | "react-fontawesome": "^1.6.1", 71 | "react-redux": "^4.4.5", 72 | "react-router": "^3.0.0", 73 | "redux": "^3.6.0", 74 | "redux-devtools-extension": "^2.13.0", 75 | "redux-logger": "^2.7.0", 76 | "redux-thunk": "^2.1.0", 77 | "sequelize": "^3.24.6", 78 | "sinon": "^1.17.6", 79 | "sinon-chai": "^2.8.0", 80 | "styled-components": "^2.1.2" 81 | }, 82 | "devDependencies": { 83 | "babel": "^6.5.2", 84 | "babel-core": "^6.18.0", 85 | "babel-eslint": "^7.2.2", 86 | "babel-loader": "^6.2.7", 87 | "babel-preset-es2015": "^6.18.0", 88 | "babel-preset-react": "^6.16.0", 89 | "chai": "^3.5.0", 90 | "cross-env": "^3.1.4", 91 | "dateformat": "^2.0.0", 92 | "eslint": "^3.19.0", 93 | "eslint-config-standard": "^10.2.1", 94 | "eslint-plugin-import": "^2.2.0", 95 | "eslint-plugin-node": "^4.2.2", 96 | "eslint-plugin-promise": "^3.5.0", 97 | "eslint-plugin-react": "^6.10.3", 98 | "eslint-plugin-standard": "^3.0.1", 99 | "eslint-watch": "^3.1.0", 100 | "mocha": "^3.1.2", 101 | "nodemon": "^1.11.0", 102 | "supertest": "^3.0.0", 103 | "volleyball": "^1.4.1", 104 | "webpack": "^2.2.1", 105 | "webpack-livereload-plugin": "^0.10.0" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/db/review.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db'), 4 | { Review } = db, 5 | { expect } = require('chai'), 6 | Promise = require('bluebird') 7 | 8 | describe('The `Review` model', () => { 9 | /** 10 | * First we clear the database and recreate the tables before beginning a run 11 | */ 12 | before('Await database sync', () => db.didSync) 13 | 14 | /** 15 | * Next, we create an (un-saved!) Review instance before every spec 16 | */ 17 | let review 18 | beforeEach(() => { 19 | review = Review.build({ 20 | title: "Funny Bike!", 21 | content: "It makes weird noise every time I ride it. haha funny", 22 | num_stars: 5 23 | }) 24 | }) 25 | 26 | /** 27 | * Also, we empty the tables after each spec 28 | */ 29 | afterEach(function () { 30 | return Promise.all([ 31 | Review.truncate({cascade: true}) 32 | ]) 33 | }) 34 | 35 | describe('attributes definition', () => { 36 | 37 | it('included `title`, `content`, `num_stars` fields', () => { 38 | return review.save() 39 | .then(savedReview => { 40 | expect(savedReview.title).to.equal("Funny Bike!") 41 | expect(savedReview.content).to.equal("It makes weird noise every time I ride it. haha funny") 42 | expect(savedReview.num_stars).to.equal(5) 43 | }) 44 | }) 45 | }) 46 | 47 | describe('validations', () => { 48 | it('requires `title`', () => { 49 | review.title = null 50 | return review.validate() 51 | .then(result => { 52 | expect(result).to.be.an.instanceOf(Error) 53 | expect(result.message).to.contain('title cannot be null') 54 | }) 55 | }) 56 | 57 | it('errors if `title` is an empty string', () => { 58 | review.title = '' 59 | return review.validate() 60 | .then(result => { 61 | expect(result).to.be.an.instanceOf(Error) 62 | expect(result.message).to.contain('Validation error') 63 | }) 64 | }) 65 | 66 | it('requires `content`', () => { 67 | 68 | review.content = null 69 | 70 | return review.validate() 71 | .then(result => { 72 | expect(result).to.be.an.instanceOf(Error) 73 | expect(result.message).to.contain('content cannot be null') 74 | }) 75 | }) 76 | 77 | it('can handle long `content`', () => { 78 | 79 | let reviewContent = 'MountainBike (stylized with an interpunct as MountainBike) is a 2008 American computer-animated science-fiction comedy film produced by Pixar Animation Studios and released by Walt Disney Pictures. Directed by Andrew Stanton, the story follows a robot named WALL-E, who is designed to clean up an abandoned, waste-covered Earth far in the future. He falls in love with another robot named EVE, who also has a programmed task, and follows her into outer space on an adventure that changes the destiny of both his kind and humanity. Both robots exhibit an appearance of free will and emotions similar to humans, which develop further as the film progresses.' 80 | 81 | return Review.create({ 82 | title: 'MountainBike', 83 | content: reviewContent, 84 | num_stars: 4.9 85 | }) 86 | .then(result => { 87 | expect(result).to.be.an('object') 88 | expect(result.title).to.equal('MountainBike') 89 | expect(result.content).to.equal(reviewContent) 90 | }) 91 | }) 92 | 93 | it('requires `num_stars`', () => { 94 | 95 | review.num_stars = null 96 | 97 | return review.validate() 98 | .then(result => { 99 | expect(result).to.be.an.instanceOf(Error) 100 | expect(result.message).to.contain('num_stars cannot be null') 101 | }) 102 | }) 103 | 104 | it('errors if `num_stars` is less than or equal to zero', () => { 105 | 106 | review.num_stars = -1 107 | 108 | return review.validate() 109 | .then(result => { 110 | expect(result).to.be.an.instanceOf(Error) 111 | expect(result.message).to.contain('Validation error') 112 | }) 113 | }) 114 | 115 | it('errors if `num_stars` is greater than 5 ', () => { 116 | 117 | review.num_stars = 7 118 | 119 | return review.validate() 120 | .then(result => { 121 | expect(result).to.be.an.instanceOf(Error) 122 | expect(result.message).to.contain('Validation error') 123 | }) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tests/db/product.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | , {Product} = db 5 | , {expect} = require('chai') 6 | 7 | /* global describe it before afterEach */ 8 | 9 | describe('The `Product` model', () => { 10 | before('Await database sync', () => db.didSync) 11 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 12 | 13 | let product 14 | 15 | beforeEach(function(){ 16 | product = Product.build({ 17 | name: 'Mens Bike', 18 | category: 'Mountain', 19 | price: 1000.75, 20 | images: [ 21 | 'http://www.placecat.com/2000/2000', 22 | 'http://www.placegoat.com/2000/2000', 23 | 'http://www.placecat.com/400/400' 24 | ], 25 | color: 'Red', 26 | size: 'Medium', 27 | quantity: 3, 28 | reviewStars: 4.7, 29 | description: 'this bike rocks' 30 | }) 31 | }) 32 | 33 | describe('includes all correct attributes,', () => { 34 | it('has correct values', () => { 35 | return product.save() 36 | .then( (savedProduct) => { 37 | expect(savedProduct.name).to.equal('Mens Bike') 38 | expect(savedProduct.category).to.equal('Mountain') 39 | expect(savedProduct.price).to.equal('1000.75') 40 | expect(savedProduct.images.length).to.equal(3) 41 | expect(savedProduct.color).to.equal('Red') 42 | expect(savedProduct.size).to.equal('Medium') 43 | expect(savedProduct.quantity).to.equal(3) 44 | expect(savedProduct.reviewStars).to.equal('4.7') 45 | expect(savedProduct.description).to.equal('this bike rocks') 46 | }) 47 | }) 48 | }) 49 | 50 | describe('Validations ', () => { 51 | it('errors when no name is entered', function () { 52 | product.name = null 53 | 54 | return product.validate() 55 | .then(function (result) { 56 | expect(result).to.be.an.instanceOf(Error) 57 | expect(result.message).to.contain('notNull Violation') 58 | }) 59 | }) 60 | 61 | it('requires `name` to not be an empty string', function () { 62 | 63 | product.name = '' 64 | 65 | return product.validate() 66 | .then(function (result) { 67 | expect(result).to.be.an.instanceOf(Error) 68 | expect(result.message).to.contain('Validation error') 69 | }) 70 | }) 71 | 72 | 73 | it('errors when no category is entered', function () { 74 | product.category = null 75 | 76 | return product.validate() 77 | .then(function (result) { 78 | expect(result).to.be.an.instanceOf(Error) 79 | expect(result.message).to.contain('notNull Violation') 80 | }) 81 | }) 82 | 83 | it('errors when no price is entered', function () { 84 | product.price = null 85 | 86 | return product.validate() 87 | .then(function (result) { 88 | expect(result).to.be.an.instanceOf(Error) 89 | expect(result.message).to.contain('notNull Violation') 90 | }) 91 | }) 92 | 93 | it('errors when price is less than 0', function () { 94 | product.price = -44 95 | 96 | return product.validate() 97 | .then(function (result) { 98 | expect(result).to.be.an.instanceOf(Error) 99 | expect(result.message).to.contain('Validation error') 100 | }) 101 | }) 102 | 103 | it('errors when no color is entered' , function () { 104 | product.color = null 105 | 106 | return product.validate() 107 | .then(function (result) { 108 | expect(result).to.be.an.instanceOf(Error) 109 | expect(result.message).to.contain('notNull Violation') 110 | }) 111 | }) 112 | 113 | it('errors when no size is entered' , function () { 114 | product.size = null 115 | 116 | return product.validate() 117 | .then(function (result) { 118 | expect(result).to.be.an.instanceOf(Error) 119 | expect(result.message).to.contain('notNull Violation') 120 | }) 121 | }) 122 | 123 | it('errors when quantity is not an integer' , function () { 124 | product.quantity = 1.5 125 | 126 | return product.validate() 127 | .then(function (result) { 128 | expect(result).to.be.an.instanceOf(Error) 129 | expect(result.message).to.contain('Validation error') 130 | }) 131 | }) 132 | 133 | it('errors when quantity is less than 0' , function () { 134 | product.quantity = -4 135 | 136 | return product.validate() 137 | .then(function (result) { 138 | expect(result).to.be.an.instanceOf(Error) 139 | expect(result.message).to.contain('Validation error') 140 | }) 141 | }) 142 | 143 | it('errors when there is no description' , function () { 144 | product.description = null 145 | 146 | return product.validate() 147 | .then(function (result) { 148 | expect(result).to.be.an.instanceOf(Error) 149 | expect(result.message).to.contain('notNull Violation') 150 | }) 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /tests/server/users.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | , { expect } = require('chai') 3 | , db = require('APP/db') 4 | , app = require('APP/server/start') 5 | , User = db.model('users') 6 | 7 | /* global describe it before afterEach */ 8 | 9 | describe('/api/users', () => { 10 | before('Await database sync', () => db.didSync) 11 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 12 | 13 | describe('GET /users', function() { 14 | // should test if it's allowed only for admins somehow 15 | it('responds with an array via JSON', function() { 16 | request(app) 17 | .get('/users') 18 | .expect('Content-Type', /json/) 19 | .expect(200) 20 | // .expect(function(res){ 21 | // console.log('test'); 22 | // // res.body is the JSON return object 23 | // expect(res.body).to.be.an.instanceOf(Array) 24 | // expect(res.body).to.have.length(0) 25 | // }) 26 | }) 27 | describe('GET /:id', () => { 28 | var theUser 29 | beforeEach(function() { 30 | var creatingSomeUsers = [{ 31 | first_name: 'Chloe', 32 | last_name: 'One', 33 | user_name: 'chloe', 34 | email: 'chloe@gmail.com', 35 | password: 'ccc', 36 | is_admin: false 37 | }, { 38 | first_name: 'Jeff', 39 | last_name: 'Two', 40 | user_name: 'jeff', 41 | email: 'jeff@gmail.com', 42 | password: 'jjj', 43 | is_admin: false 44 | }, { 45 | first_name: 'Gabe', 46 | last_name: 'Three', 47 | user_name: 'gabe', 48 | email: 'gabe@gmail.com', 49 | password: 'ggg', 50 | is_admin: true 51 | }] 52 | .map(data => User.create(data)) 53 | 54 | return Promise.all(creatingSomeUsers) 55 | .then(createdUsers => { 56 | theUser = createdUsers[1] 57 | }) 58 | }) 59 | // here we would want to make this fancy check 60 | it('fails with a 401 (Unauthorized)', () => { 61 | request(app) 62 | .get('/api/users/0') 63 | .expect(401) 64 | }) 65 | }) 66 | 67 | describe('POST', () => 68 | describe('when not logged in', () => { 69 | it('creates a user', () => 70 | request(app) 71 | .post('/api/users') 72 | .send({ 73 | first_name: 'beth', 74 | last_name: 'secret', 75 | user_name: 'bethsecret', 76 | email: 'beth@secrets.org', 77 | password: '12345', 78 | is_admin: false 79 | }) 80 | .expect(201)) 81 | 82 | // Check if the new user was saved to the database 83 | it('saves the user to the DB', function() { 84 | request(app) 85 | .post('/api/users') 86 | .send({ 87 | first_name: 'Simon', 88 | last_name: 'Cat', 89 | user_name: 'simoncat', 90 | email: 'scat@gmail.com', 91 | password: 'sssccc', 92 | is_admin: false 93 | }) 94 | .expect(201) 95 | .then(function() { 96 | return User.findOne({ 97 | where: { email: 'scat@gmail.com' } 98 | }) 99 | }) 100 | .then(function(foundUser) { 101 | expect(foundUser).to.exist 102 | expect(foundUser.email).to.equal('scat@gmail.com') 103 | }) 104 | }) 105 | 106 | it('redirects to the user it just made', () => 107 | request(app) 108 | .post('/api/users') 109 | .send({ 110 | first_name: 'Simon', 111 | last_name: 'Cat', 112 | user_name: 'simoncat', 113 | email: 'scat@gmail.com', 114 | password: 'sssccc', 115 | is_admin: false 116 | }) 117 | .redirects(1) 118 | .then(res => expect(res.body).to.contain({ 119 | email: 'scat@gmail.com' 120 | }))) 121 | })) 122 | 123 | describe('PUT /users/:id', function() { 124 | let user 125 | beforeEach(function() { 126 | return User.create({ 127 | first_name: 'Simon', 128 | last_name: 'Cat', 129 | user_name: 'simoncat', 130 | email: 'scat@gmail.com', 131 | password: 'sssccc', 132 | is_admin: false 133 | }) 134 | .then(function(createdUser) { 135 | user = createdUser 136 | }) 137 | }) 138 | 139 | it('updates a user', function() { 140 | request(app) 141 | .put('/users/' + user.id) 142 | .send({ 143 | first_name: 'Simon Simon' 144 | }) 145 | .expect(200) 146 | .expect(function(res) { 147 | expect(res.body.user.id).to.not.be.an('undefined') 148 | expect(res.body.user.first_name).to.equal('Simon Simon') 149 | }) 150 | }) 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | const app = require('APP'), { env } = app 2 | const debug = require('debug')(`${app.name}:auth`) 3 | const passport = require('passport') 4 | 5 | const { User, OAuth, Order } = require('APP/db') 6 | const auth = require('express').Router() 7 | 8 | /************************* 9 | * Auth strategies 10 | * 11 | * The OAuth model knows how to configure Passport middleware. 12 | * To enable an auth strategy, ensure that the appropriate 13 | * environment variables are set. 14 | * 15 | * You can do it on the command line: 16 | * 17 | * FACEBOOK_CLIENT_ID=abcd FACEBOOK_CLIENT_SECRET=1234 npm run dev 18 | * 19 | * Or, better, you can create a ~/.$your_app_name.env.json file in 20 | * your home directory, and set them in there: 21 | * 22 | * { 23 | * FACEBOOK_CLIENT_ID: 'abcd', 24 | * FACEBOOK_CLIENT_SECRET: '1234', 25 | * } 26 | * 27 | * Concentrating your secrets this way will make it less likely that you 28 | * accidentally push them to Github, for example. 29 | * 30 | * When you deploy to production, you'll need to set up these environment 31 | * variables with your hosting provider. 32 | **/ 33 | 34 | // Facebook needs the FACEBOOK_CLIENT_ID and FACEBOOK_CLIENT_SECRET 35 | // environment variables. 36 | OAuth.setupStrategy({ 37 | provider: 'facebook', 38 | strategy: require('passport-facebook').Strategy, 39 | config: { 40 | clientID: env.FACEBOOK_CLIENT_ID, 41 | clientSecret: env.FACEBOOK_CLIENT_SECRET, 42 | callbackURL: `${app.baseUrl}/api/auth/login/facebook`, 43 | }, 44 | passport 45 | }) 46 | 47 | // Google needs the GOOGLE_CLIENT_SECRET AND GOOGLE_CLIENT_ID 48 | // environment variables. 49 | OAuth.setupStrategy({ 50 | provider: 'google', 51 | strategy: require('passport-google-oauth').OAuth2Strategy, 52 | config: { 53 | clientID: env.GOOGLE_CLIENT_ID, 54 | clientSecret: env.GOOGLE_CLIENT_SECRET, 55 | callbackURL: `${app.baseUrl}/api/auth/login/google`, 56 | }, 57 | passport 58 | }) 59 | 60 | // Github needs the GITHUB_CLIENT_ID AND GITHUB_CLIENT_SECRET 61 | // environment variables. 62 | OAuth.setupStrategy({ 63 | provider: 'github', 64 | strategy: require('passport-github2').Strategy, 65 | config: { 66 | clientID: env.GITHUB_CLIENT_ID, 67 | clientSecret: env.GITHUB_CLIENT_SECRET, 68 | callbackURL: `${app.baseUrl}/api/auth/login/github`, 69 | }, 70 | passport 71 | }) 72 | 73 | // Other passport configuration: 74 | // Passport review in the Week 6 Concept Review: 75 | // https://docs.google.com/document/d/1MHS7DzzXKZvR6MkL8VWdCxohFJHGgdms71XNLIET52Q/edit?usp=sharing 76 | passport.serializeUser((user, done) => { 77 | done(null, user.id) 78 | }) 79 | 80 | passport.deserializeUser( 81 | (id, done) => { 82 | debug('will deserialize user.id=%d', id) 83 | User.scope('currentOrder').findById(id) 84 | .then(user => { 85 | if (!user) debug('deserialize retrieved null user for id=%d', id) 86 | else debug('deserialize did ok user.id=%d', id) 87 | done(null, user) 88 | }) 89 | .catch(err => { 90 | debug('deserialize did fail err=%s', err) 91 | done(err) 92 | }) 93 | } 94 | ) 95 | 96 | // require.('passport-local').Strategy => a function we can use as a constructor, that takes in a callback 97 | passport.use(new (require('passport-local').Strategy)( 98 | (email, password, done) => { 99 | debug('will authenticate user(email: "%s")', email) 100 | User.scope('currentOrder').findOne({ 101 | where: {email}, 102 | attributes: {include: ['password_digest']}, 103 | }) 104 | .then(user => { 105 | if (!user) { 106 | debug('authenticate user(email: "%s") did fail: no such user', email) 107 | return done(null, false, { message: 'Login incorrect' }) 108 | } 109 | return user.authenticate(password) 110 | .then(ok => { 111 | if (!ok) { 112 | debug('authenticate user(email: "%s") did fail: bad password') 113 | return done(null, false, { message: 'Login incorrect' }) 114 | } 115 | debug('authenticate user(email: "%s") did ok: user.id=%d', email, user.id) 116 | done(null, user) 117 | }) 118 | }) 119 | .catch(done) 120 | } 121 | )) 122 | 123 | auth.get('/whoami', (req, res) => { 124 | res.send(req.user) 125 | }) 126 | 127 | // POST requests for local login: 128 | auth.post('/login/local', passport.authenticate('local', {successRedirect: '/'})) 129 | 130 | // GET requests for OAuth login: 131 | // Register this route as a callback URL with OAuth provider 132 | auth.get('/login/:strategy', (req, res, next) => { 133 | passport.authenticate(req.params.strategy, { 134 | scope: 'email', // You may want to ask for additional OAuth scopes. These are 135 | // provider specific, and let you access additional data (like 136 | // their friends or email), or perform actions on their behalf. 137 | successRedirect: '/', 138 | // Specify other config here 139 | })(req, res, next) 140 | }) 141 | 142 | auth.post('/logout', (req, res) => { 143 | req.logout() 144 | res.redirect('/api/auth/whoami') 145 | }) 146 | 147 | module.exports = auth 148 | -------------------------------------------------------------------------------- /tests/reducers/product.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {expect} from 'chai' 3 | import {createStore} from 'redux' 4 | import {shallow} from 'enzyme' 5 | 6 | import rootReducer from 'APP/app/reducers/index' 7 | import {setProduct, addProductToOrder} from 'APP/app/reducers/product' 8 | import {AllProducts} from 'APP/app/components/Products/AllProducts' 9 | 10 | describe('Product actions', () => { 11 | const testProduct = { 12 | name: 'TheBike', 13 | category: 'Test', 14 | price: 1000, 15 | color: ['White', 'Red', 'Black'], 16 | size: ['Large', 'Medium', 'Small'], 17 | images: ['http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-org-2100.jpg'], 18 | quantity: 8, 19 | reviewStars: 4.5, 20 | description: 'This is a bike created only for this test' 21 | } 22 | describe('setProduct', () => { 23 | 24 | it('returns properly formatted action', () => { 25 | 26 | expect(setProduct(testProduct)).to.be.deep.equal({ 27 | type: 'SET_SELECTED_PRODUCT', 28 | selectedProduct: testProduct 29 | }) 30 | }) 31 | }) 32 | 33 | describe('addProductToOrder', () => { 34 | 35 | it('returns properly formatted action', () => { 36 | 37 | expect(addProductToOrder(testProduct)).to.be.deep.equal({ 38 | type: 'ADD_PRODUCT_TO_ORDER', 39 | product: testProduct 40 | }) 41 | }) 42 | }) 43 | }) 44 | 45 | describe('Product reducer', () => { 46 | const testProduct = { 47 | name: 'TheBike', 48 | category: 'Test', 49 | price: 1000, 50 | color: ['White', 'Red', 'Black'], 51 | size: ['Large', 'Medium', 'Small'], 52 | images: ['http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-org-2100.jpg'], 53 | quantity: 8, 54 | reviewStars: 4.5, 55 | description: 'This is a bike created only for this test' 56 | } 57 | let testStore 58 | beforeEach('Create testing store', () => { 59 | testStore = createStore(rootReducer) 60 | }) 61 | 62 | it('has expected initial state', () => { 63 | expect(testStore.getState().product).to.be.deep.equal( 64 | { 65 | products: [], 66 | selectedProduct: {} 67 | } 68 | ) 69 | }) 70 | 71 | describe('SET_SELECTED_PRODUCT', () => { 72 | 73 | it('sets selectedProduct to action selectedProduct', () => { 74 | testStore.dispatch({ type: 'SET_SELECTED_PRODUCT', selectedProduct: testProduct }) 75 | const newState = testStore.getState() 76 | expect(newState.product.selectedProduct).to.be.deep.equal(testProduct) 77 | }) 78 | }) 79 | }) 80 | 81 | describe.only('AllProducts Component', () => { 82 | let products = [ 83 | { 84 | id: 1, 85 | name: "RoadMaster X-Treme", 86 | category: "Road", 87 | price: 135900, 88 | images: [ 89 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-org-2100.jpg", 90 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-wht-2100.jpg", 91 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-blk-2100.jpg" 92 | ], 93 | color: [ 94 | "White", 95 | "Red", 96 | "Black" 97 | ], 98 | size: [ 99 | "Large", 100 | "Medium", 101 | "Small" 102 | ], 103 | quantity: 7480, 104 | reviewStars: "3.9", 105 | description: "SO EXTREME YOUR FACE WILL MELT! us vestibulum sagittis sapien cum sociis natoque penatibus et magnis dis parturient montes nascetur ridiculus mus etiam vel augue vestibulum rutrum rutrum neque aenean auctor gravida sem praesent id massa id nisl venenatis lacinia aenean sit amet justo morbi ut odio cras mi pede malesuada in", 106 | created_at: "2017-04-24T18:13:57.174Z", 107 | updated_at: "2017-04-24T18:13:57.174Z" 108 | }, 109 | { 110 | id: 2, 111 | name: "Mount-Pain X-FIRE", 112 | category: "Mountain", 113 | price: 210051, 114 | images: [ 115 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-org-2100.jpg", 116 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-wht-2100.jpg" 117 | ], 118 | color: [ 119 | "White", 120 | "Red", 121 | "Pink" 122 | ], 123 | size: [ 124 | "Large", 125 | "Medium", 126 | "Small" 127 | ], 128 | quantity: 2403, 129 | reviewStars: "3.9", 130 | description: "SUCH PAIN AHHH! us vestibulum sagittis sapien cum sociis natoque penatibus et magnis dis parturient montes nascetur ridiculus mus etiam vel augue vestibulum rutrum rutrum neque aenean auctor gravida sem praesent id massa id nisl venenatis lacinia aenean sit amet justo morbi ut odio cras mi pede malesuada in", 131 | created_at: "2017-04-24T18:13:57.174Z", 132 | updated_at: "2017-04-24T18:13:57.174Z" 133 | }, 134 | { 135 | id: 3, 136 | name: "Mount-Pain X-FIRE", 137 | category: "Mountain", 138 | price: 210052, 139 | images: [ 140 | "http://www.bikesdirect.com/products/gravity/images/avenue-a-xiv-blk-2100.jpg" 141 | ], 142 | color: [ 143 | "Red", 144 | "Blue" 145 | ], 146 | size: [ 147 | "Large", 148 | "Medium", 149 | "Small" 150 | ], 151 | quantity: 2403, 152 | reviewStars: "3.2", 153 | description: "SUCH PAIN AHHH! MEDIUM IS ON THE SMALL SIDE OF THINGS! us vestibulum sagittis sapien cum sociis natoque penatibus et magnis dis parturient montes nascetur ridiculus mus etiam vel augue vestibulum rutrum rutrum neque aenean auctor gravida sem praesent id massa id nisl venenatis lacinia aenean sit amet justo morbi ut odio cras mi pede malesuada in", 154 | created_at: "2017-04-24T18:13:57.175Z", 155 | updated_at: "2017-04-24T18:13:57.175Z" 156 | } 157 | ] 158 | let all_products = shallow() 159 | 160 | it('should be a
', () => { 161 | expect(all_products.is('div')).to.be.equal(true); 162 | }) 163 | }) 164 | -------------------------------------------------------------------------------- /db/seed.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* 4 | * To Seed Your Local Database 5 | * Check Postico... do you have a climbshopper database? 6 | * NO: npm run dev will create the database 7 | * YES: npm run seed 8 | */ 9 | 10 | const db = require('APP/db'), 11 | { User, Product, Order, Item, Promise } = db, 12 | { mapValues } = require('lodash') 13 | 14 | function seedEverything() { 15 | const seeded = { 16 | users: users(), 17 | products: products(), 18 | } 19 | 20 | seeded.orders = orders(seeded) 21 | seeded.items = items(seeded) 22 | 23 | return Promise.props(seeded) 24 | } 25 | 26 | const users = seed(User, { 27 | adam: { 28 | first_name: 'Adam', 29 | last_name: 'Kim', 30 | email: 'akim@na.com', 31 | user_name: 'akim', 32 | password: '123', 33 | is_admin: false 34 | }, 35 | kathy: { 36 | first_name: 'Kathy', 37 | last_name: 'Bailey', 38 | email: 'kbai@go.com', 39 | user_name: 'kbailey', 40 | password: '123', 41 | is_admin: false 42 | }, 43 | deborah: { 44 | first_name: 'Deborah', 45 | last_name: 'Walker', 46 | email: 'dwalker2@ning.com', 47 | user_name: 'dwalker', 48 | password: 'aJA7P3', 49 | is_admin: true 50 | } 51 | }) 52 | 53 | const products = seed(Product, { 54 | red_rock_sport: { 55 | name: 'Red Rock Canyon Sport', 56 | category: 'Sport', 57 | price: 135900, 58 | images: '1.jpg', 59 | quantity: 5, 60 | description: 'What a wonderful place. Just selling the sport climbs!' 61 | }, 62 | el_chorro: { 63 | name: 'El Chorro', 64 | category: 'Sport', 65 | price: 185900, 66 | images: '2.jpg', 67 | quantity: 5, 68 | description: 'Come experience the beautiful limestone cliffs of El Chorro in southern Spain' 69 | }, 70 | ceuse: { 71 | name: 'Ceuse', 72 | category: 'Sport', 73 | price: 135900, 74 | images: '3.jpg', 75 | quantity: 5, 76 | description: 'Come take in the breathtaking views and climbing in the south of France.' 77 | }, 78 | torres_del_paine: { 79 | name: 'Torres Del Paine', 80 | category: 'Trad', 81 | price: 210051, 82 | images: '4.jpg', 83 | quantity: 10, 84 | description: 'One of the great beauties of the world' 85 | }, 86 | yosemite: { 87 | name: 'Yosemite National Park', 88 | category: 'Trad', 89 | price: 20010051, 90 | images: '5.jpg', 91 | quantity: 1, 92 | description: 'Mecca. Come try your hand on the legendary walls of Yosemite' 93 | }, 94 | red_rock_trad: { 95 | name: 'Red Rock Canyon Trad', 96 | category: 'Trad', 97 | price: 210051, 98 | images: '9.jpg', 99 | quantity: 4, 100 | description: 'Neighboring some of the best sport climbing in the world is some of the best trad climbing in the world. Treat yourself.' 101 | }, 102 | rocky_mountain: { 103 | name: 'Rocky Mountain National Park', 104 | category: 'Bouldering', 105 | price: 210052, 106 | images: '6.jpg', 107 | quantity: 4, 108 | description: 'High mountains, perfect bouldering' 109 | }, 110 | bishop: { 111 | name: 'Bishop', 112 | category: 'Bouldering', 113 | price: 40052, 114 | images: '7.jpg', 115 | quantity: 4, 116 | description: 'Bouldering set to the back drop of the Californian high mountains. Spectacular, hard, scary, and most of all -- fun.' 117 | }, 118 | hueco_tanks: { 119 | name: 'Hueco Tanks State Park', 120 | category: 'Bouldering', 121 | price: 400052, 122 | images: '8.jpg', 123 | quantity: 3, 124 | description: 'The birthplace of hardcore bouldering-- the one and only Hueco Tanks' 125 | }, 126 | }) 127 | 128 | const orders = seed(Order, 129 | ({ users }) => ({ 130 | orderOne: { 131 | status: 'Pending', 132 | user_id: users.adam.id 133 | }, 134 | orderTwo: { 135 | status: 'Pending', 136 | user_id: users.kathy.id 137 | }, 138 | orderThree: { 139 | status: 'Pending', 140 | user_id: users.deborah.id 141 | }, 142 | orderFour: { 143 | status: 'Complete', 144 | user_id: users.kathy.id 145 | }, 146 | }) 147 | ) 148 | 149 | const items = seed(Item, 150 | ({ orders, products }) => ({ 151 | 'orderOne has Torres Del Paine': { 152 | price: 210051, 153 | quantity: 2, 154 | order_id: orders.orderOne.id, 155 | product_id: products.torres_del_paine.id 156 | }, 157 | 'orderTwo has Torres Del Paine': { 158 | price: 160051, 159 | quantity: 1, 160 | order_id: orders.orderTwo.id, 161 | product_id: products.torres_del_paine.id 162 | }, 163 | 'orderThree has Red Rock sport climbing': { 164 | price: 210052, 165 | quantity: 1, 166 | order_id: orders.orderThree.id, 167 | product_id: products.red_rock_sport.id 168 | }, 169 | 'orderThree has Rocky Mountain National Park': { 170 | price: 210053, 171 | quantity: 1, 172 | order_id: orders.orderThree.id, 173 | product_id: products.rocky_mountain.id 174 | }, 175 | 'orderFour has Rocky Mountain National Park': { 176 | price: 150004, 177 | quantity: 3, 178 | order_id: orders.orderFour.id, 179 | product_id: products.rocky_mountain.id 180 | }, 181 | 'orderFour has Red Rock sport climbing': { 182 | price: 210058, 183 | quantity: 2, 184 | order_id: orders.orderFour.id, 185 | product_id: products.red_rock_sport.id 186 | }, 187 | }) 188 | ) 189 | 190 | if (module === require.main) { 191 | db.didSync 192 | .then(() => db.sync({ force: true })) 193 | .then(seedEverything) 194 | .finally(() => process.exit(0)) 195 | } 196 | 197 | class BadRow extends Error { 198 | constructor(key, row, error) { 199 | super(error) 200 | this.cause = error 201 | this.row = row 202 | this.key = key 203 | } 204 | 205 | toString() { 206 | return `[${this.key}] ${this.cause} while creating ${JSON.stringify(this.row, 0, 2)}` 207 | } 208 | } 209 | 210 | // seed(Model: Sequelize.Model, rows: Function|Object) -> 211 | // (others?: {...Function|Object}) -> Promise 212 | // 213 | // Takes a model and either an Object describing rows to insert, 214 | // or a function that when called, returns rows to insert. returns 215 | // a function that will seed the DB when called and resolve with 216 | // a Promise of the object of all seeded rows. 217 | // 218 | // The function form can be used to initialize rows that reference 219 | // other models. 220 | function seed(Model, rows) { 221 | return (others = {}) => { 222 | if (typeof rows === 'function') { 223 | rows = Promise.props( 224 | mapValues(others, 225 | other => 226 | // Is other a function? If so, call it. Otherwise, leave it alone. 227 | typeof other === 'function' ? other() : other) 228 | ).then(rows) 229 | } 230 | 231 | return Promise.resolve(rows) 232 | .then(rows => Promise.props( 233 | Object.keys(rows) 234 | .map(key => { 235 | const row = rows[key] 236 | return { 237 | key, 238 | value: Promise.props(row) 239 | .then(row => Model.create(row) 240 | .catch(error => { 241 | throw new BadRow(key, row, error) 242 | }) 243 | ) 244 | } 245 | }).reduce( 246 | (all, one) => Object.assign({}, all, { 247 | [one.key]: one.value 248 | }), {} 249 | ) 250 | )) 251 | .then(seeded => { 252 | console.log(`Seeded ${Object.keys(seeded).length} ${Model.name} OK`) 253 | return seeded 254 | }).catch(error => { 255 | console.error(`Error seeding ${Model.name}: ${error} \n${error.stack}`) 256 | }) 257 | } 258 | } 259 | 260 | module.exports = Object.assign(seed, { users, orders, items, products }) 261 | --------------------------------------------------------------------------------