├── resources └── e_commerce_ERD.png ├── .gitignore ├── db ├── index.js ├── config.js └── init.sql ├── index.js ├── routes ├── shop │ ├── home.js │ ├── cart.js │ └── products.js ├── index.js ├── auth │ ├── logout.js │ ├── login.js │ └── register.js ├── checkout │ ├── index.js │ ├── shipping.js │ ├── payment.js │ ├── auth.js │ └── order.js └── account │ ├── index.js │ └── orders.js ├── lib ├── csrf.js ├── sanitizer.js ├── customAuth │ ├── passwordUtils.js │ ├── attachJWT.js │ ├── genKeyPair.js │ └── jwtAuth.js ├── formatUtils.js └── cartConsolidator.js ├── tests ├── home.test.js ├── testData │ ├── removeAllData.js │ ├── initTestData.sql │ ├── removeTestData.js │ └── initTestData.js ├── products.test.js ├── README.md ├── orders.test.js ├── testUtils.js ├── cart.test.js └── account.test.js ├── app ├── index.js ├── express.js ├── swagger.js └── passport.js ├── package.json ├── services ├── productService.js ├── accountService.js ├── cartService.js ├── authService.js ├── cartItemService.js ├── checkoutService.js ├── paymentService.js ├── addressService.js └── orderService.js ├── models ├── ProductModel.js ├── OrderItemModel.js ├── CardModel.js ├── AddressModel.js ├── OrderModel.js ├── UserModel.js └── CartItemModel.js └── README.md /resources/e_commerce_ERD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carokrny/e-commerce/HEAD/resources/e_commerce_ERD.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._.DS_Store 3 | **/.DS_Store 4 | **/._.DS_Store 5 | .env 6 | priv_key.pem 7 | pub_key.pem 8 | /templates 9 | .vscode/ 10 | *node_modules/* -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const pool = require('./config'); 3 | 4 | // export query 5 | module.exports = { 6 | query: (text, params) => { 7 | return pool.query(text, params) 8 | } 9 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const app = require('./app'); 3 | 4 | // Bind to port to start server 5 | app.listen(process.env.PORT, () => { 6 | console.log(`Express server started at port ${process.env.PORT}`); 7 | }); -------------------------------------------------------------------------------- /routes/shop/home.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | module.exports = (app) => { 4 | 5 | app.use('/', router); 6 | 7 | /** 8 | * @swagger 9 | * /: 10 | * get: 11 | * tags: 12 | * - Shop 13 | * summary: Returns home page 14 | * responses: 15 | * 200: 16 | * description: returns csrfToken. 17 | */ 18 | router.get('/', (req, res, next) => { 19 | res.status(200).json({csrfToken: req.csrfToken()}); 20 | }); 21 | 22 | } -------------------------------------------------------------------------------- /lib/csrf.js: -------------------------------------------------------------------------------- 1 | const csrf = require('csurf'); 2 | require('dotenv').config(); 3 | 4 | // middleware that attaches a _csrf cookie and validates the header 5 | module.exports.csrfProtection = csrf({ 6 | cookie: { 7 | maxAge: 60 * 60 * 24, // 1 day 8 | secure: process.env.NODE_ENV === 'production', 9 | httpOnly: true 10 | } 11 | }); 12 | 13 | 14 | // middleware that attaches the XSRF-TOKEN cookie 15 | module.exports.attachCSRF = (req, res, next) => { 16 | if(!req.cookies[`XSRF-TOKEN`]) { 17 | res.cookie('XSRF-TOKEN', req.csrfToken()); 18 | } 19 | next(); 20 | } 21 | -------------------------------------------------------------------------------- /tests/home.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const session = require('supertest-session'); 3 | 4 | describe('Home page endpoint', () => { 5 | 6 | describe('GET \'/\'', () => { 7 | 8 | it ('should return a home page', async () => { 9 | testSession = session(app); 10 | 11 | const res = await testSession 12 | .get('/') 13 | .set('Accept', 'application/json') 14 | .expect('Content-Type', /json/) 15 | .expect(200); 16 | expect(res.body).toBeDefined(); 17 | expect(res.body.csrfToken).toBeDefined(); 18 | }) 19 | }) 20 | }) -------------------------------------------------------------------------------- /db/config.js: -------------------------------------------------------------------------------- 1 | const { Pool, types } = require('pg'); 2 | require('dotenv').config(); 3 | 4 | // Heroku sets NODE_ENV to 'production' 5 | const isProduction = process.env.NODE_ENV === 'production'; 6 | 7 | // connection string for development 8 | const devConfig = `postgresql://${process.env.PGUSER}:${process.env.PGPASSWORD}@${process.env.PGHOST}:${process.env.PGPORT}/${process.env.PGDATABASE}`; 9 | 10 | // cast numeric (OID 1700) as float (string is default in node-postgres) 11 | types.setTypeParser(1700, function(val) { 12 | return parseFloat(val); 13 | }); 14 | 15 | // instantiate pool 16 | const pool = new Pool({ 17 | connectionString: isProduction ? process.env.DATABASE_URL : devConfig, 18 | ssl: isProduction ? { rejectUnauthorized: false } : false 19 | }); 20 | 21 | module.exports = pool; -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const homeRouter = require('./shop/home'); 2 | const productsRouter = require('./shop/products'); 3 | const cartRouter = require('./shop/cart'); 4 | const checkoutRouter = require('./checkout'); 5 | const registerRouter = require('./auth/register'); 6 | const loginRouter = require('./auth/login'); 7 | const logoutRouter = require('./auth/logout'); 8 | const accountRouter = require('./account'); 9 | const { attachCSRF } = require('../lib/csrf'); 10 | 11 | module.exports = (app) => { 12 | 13 | // have all routes attach a CSRF token 14 | app.all('*', attachCSRF) 15 | 16 | homeRouter(app); 17 | registerRouter(app); 18 | loginRouter(app); 19 | logoutRouter(app); 20 | accountRouter(app); 21 | productsRouter(app); 22 | cartRouter(app); 23 | checkoutRouter(app); 24 | } -------------------------------------------------------------------------------- /routes/auth/logout.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { isAuth } = require('../../lib/customAuth/jwtAuth'); 3 | 4 | module.exports = (app) => { 5 | 6 | app.use('/logout', router); 7 | 8 | /** 9 | * @swagger 10 | * /logout: 11 | * post: 12 | * tags: 13 | * - Auth 14 | * summary: Logs user out 15 | * security: 16 | * - bearerJWT: [] 17 | * - cookieJWT: [] 18 | * responses: 19 | * 200: 20 | * description: Logout confirmation. 21 | * 401: 22 | * $ref: '#/components/responses/UnauthorizedError' 23 | */ 24 | router.post('/', isAuth, (req, res, next) => { 25 | if (req.jwt) delete req.jwt; 26 | res.clearCookie("access_token"); 27 | res.clearCookie("connect.sid"); 28 | res.status(200).json('You have successfully been logged out.'); 29 | }) 30 | } -------------------------------------------------------------------------------- /lib/sanitizer.js: -------------------------------------------------------------------------------- 1 | const validator = require('validator'); 2 | /** 3 | * Custom sanitizer middleware, to sanitize and escape inputs 4 | * 5 | * Helps protect against XSS attacks 6 | * */ 7 | 8 | // helper function to sanitize each value 9 | const sanitizeValue = (value) => { 10 | if(value !== null && typeof value === 'string') { 11 | // trim excess spaces 12 | value = validator.trim(value); 13 | 14 | // strip low ASCII char, which are not usually visible 15 | value = validator.stripLow(value); 16 | 17 | // replaces <, >, &, ', " and / with their corresponding HTML entities 18 | value = validator.escape(value); 19 | } 20 | return value; 21 | } 22 | 23 | 24 | module.exports.sanitizer = (req, res, next) => { 25 | if (req.body) { 26 | 27 | // sanitize each user input in req.body 28 | for (const property in req.body) { 29 | req.body[property] = sanitizeValue(req.body[property]); 30 | } 31 | } 32 | 33 | next(); 34 | } -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const expressLoader = require('./express'); 4 | const routeLoader = require('../routes'); 5 | const passportLoader = require('./passport'); 6 | const swaggerLoader = require('./swagger'); 7 | 8 | // load express app 9 | async function loadApp() { 10 | 11 | // load basic express middleware 12 | expressLoader(app); 13 | 14 | /** 15 | * Taking passport out in favor of custom jwt middlware 16 | */ 17 | // // configure passport 18 | // const passport = await passportLoader(app); 19 | 20 | // // configure routes with passport 21 | // routeLoader(app, passport); 22 | 23 | // load routes without passport 24 | routeLoader(app); 25 | 26 | // create API documentation with Swagger 27 | swaggerLoader(app); 28 | 29 | // lastly, add custom error-handling middleware 30 | app.use((err, req, res, next) => { 31 | const status = err.status || 500; 32 | res.status(status).json(err.message); 33 | }); 34 | } 35 | 36 | loadApp(); 37 | 38 | module.exports = app; -------------------------------------------------------------------------------- /lib/customAuth/passwordUtils.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | const workFactor = 12; 3 | 4 | /** 5 | * Returns obj with salt and hash of a plain text password 6 | * 7 | * @param {string} pw plain text password 8 | * @return {Object} containing salt and hash properties 9 | */ 10 | module.exports.genPassword = async (pw) => { 11 | // generate pseudorandom hex to be salt 12 | const salt = await bcrypt.genSalt(workFactor); 13 | 14 | // generate hash from salted pw 15 | const hash = await bcrypt.hash(pw, salt); 16 | 17 | return { 18 | salt: salt, 19 | hash: hash 20 | }; 21 | } 22 | 23 | /** 24 | * Returns true if password is valid 25 | * 26 | * @param {string} pw plain text password 27 | * @param {string} hash password hash 28 | * @param {string} salt psuedorandom hex value 29 | * @return {boolean} returns true if hash fn of salted pw matches hash 30 | */ 31 | module.exports.validPassword = async (pw, hash, salt) => { 32 | // generate hash of salted pw using same cryptographic function as genPassword 33 | const pwHash = await bcrypt.hash(pw, salt); 34 | 35 | return hash === pwHash; 36 | } -------------------------------------------------------------------------------- /lib/customAuth/attachJWT.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | require('dotenv').config(); 3 | const PRIV_KEY = Buffer.from(process.env.PRIV_KEY, 'base64').toString('utf8'); 4 | 5 | // define JWT to expire after one day 6 | const expiresIn = 60 * 60 * 24; 7 | 8 | /** 9 | * Returns JSON Web Token associated with user 10 | * 11 | * @param {Object} user id of user will become `sub` in JWT 12 | * @return {Object} JWT wih token and expires properties 13 | */ 14 | module.exports.attachJWT = (user) => { 15 | 16 | // check for valid inputs 17 | const { id } = user; 18 | if (!id) { 19 | return null; 20 | } 21 | 22 | // define JWT payload 23 | const payload = { 24 | sub: id, 25 | iat: Date.now() 26 | } 27 | 28 | // create a signed JWT 29 | const signedToken = jwt.sign(payload, PRIV_KEY, { 30 | expiresIn: expiresIn, 31 | algorithm: 'RS256' 32 | }); 33 | 34 | // return object with user, token, and time token expires at 35 | return { 36 | user: user, 37 | token: signedToken, 38 | expires: expiresIn 39 | } 40 | }; 41 | 42 | module.exports.JWTcookieOptions = { 43 | httpOnly: true, 44 | maxAge: expiresIn * 1000, // x1000 since cookie is in milliseconds 45 | secure: process.env.NODE_ENV === "production", 46 | } 47 | -------------------------------------------------------------------------------- /tests/testData/removeAllData.js: -------------------------------------------------------------------------------- 1 | const db = require('../../db'); 2 | const { users } = require('./index'); 3 | 4 | /** 5 | * Funtion to remove **ALL** data from associated tables in database 6 | * To run: 7 | * node tests/testData/removeAllData.js 8 | */ 9 | const removeAllData = async () => { 10 | console.log('Removing ALL data from associated tables in database...') 11 | 12 | /** 13 | * First, update primary address and primary payment of user7 to enable 14 | * address and payment method to be deleted. 15 | */ 16 | 17 | // pg statement 18 | let statement = `UPDATE users 19 | SET primary_address_id = $1, primary_payment_id = $2 20 | WHERE id=${users.user7.id};`; 21 | 22 | // pg values 23 | let values = [ null, null ]; 24 | 25 | try { 26 | // make query 27 | await db.query(statement, values); 28 | } catch(e) {} 29 | 30 | /** 31 | * Then remove rest of data 32 | */ 33 | 34 | // pg statement 35 | statement = ` 36 | DELETE FROM order_items; 37 | DELETE FROM orders; 38 | DELETE FROM cards; 39 | DELETE FROM addresses; 40 | DELETE FROM cart_items; 41 | DELETE FROM carts; 42 | DELETE FROM products; 43 | DELETE FROM users; 44 | DELETE FROM session;`; 45 | 46 | // pg values 47 | values = []; 48 | 49 | try { 50 | // make query 51 | await db.query(statement, values); 52 | } catch(e) { 53 | console.log('Error removing all data.') 54 | } 55 | 56 | console.log('Done!'); 57 | } 58 | 59 | // run function 60 | removeAllData(); -------------------------------------------------------------------------------- /app/express.js: -------------------------------------------------------------------------------- 1 | const bodyParser = require('body-parser'); 2 | const cookieParser = require('cookie-parser'); 3 | const helmet = require('helmet'); 4 | const session = require('express-session'); 5 | const pgSession = require('connect-pg-simple')(session); 6 | const pool = require('../db/config'); 7 | const { sanitizer } = require('../lib/sanitizer'); 8 | const { csrfProtection } = require('../lib/csrf'); 9 | require('dotenv').config(); 10 | 11 | 12 | module.exports = (app) => { 13 | 14 | // helmet for added security on http headers 15 | app.use(helmet()); 16 | 17 | // parse incoming json req 18 | app.use(bodyParser.json()); 19 | 20 | // parse incoming url-encoded form data 21 | app.use(bodyParser.urlencoded({ extended: true })); 22 | 23 | // parse all cookies into req.cookie and req.signedCookie 24 | app.use(cookieParser()); 25 | 26 | // custom middleware to sanitize user inputs 27 | app.use(sanitizer); 28 | 29 | // trust first proxy for session cookie secure 30 | app.set('trust proxy', 1); 31 | 32 | // enable session for persistent cart 33 | app.use(session ({ 34 | store: new pgSession({ 35 | pool: pool, 36 | tableName: 'session' 37 | }), 38 | secret: process.env.SESSION_SECRET, 39 | resave: false, 40 | saveUninitialized: true, 41 | cookie: { 42 | maxAge: 1000 * 60 * 60 * 24, // 1 day 43 | secure: process.env.NODE_ENV === 'production', 44 | httpOnly: true 45 | } 46 | })); 47 | 48 | // protect routes from CSRF attacks 49 | app.use(csrfProtection); 50 | 51 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e-commerce", 3 | "version": "1.0.0", 4 | "description": "e-commerce backend project ", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "node tests/testData/initTestData.js", 8 | "test": "jest", 9 | "posttest": "node tests/testData/removeTestData.js", 10 | "install": "node lib/customAuth/genKeyPair.js", 11 | "start": "npx nodemon index.js" 12 | }, 13 | "author": "Carolyn Kearney", 14 | "license": "ISC", 15 | "dependencies": { 16 | "bcrypt": "^5.0.1", 17 | "body-parser": "^1.19.0", 18 | "connect-pg-simple": "^7.0.0", 19 | "cookie-parser": "^1.4.6", 20 | "cors": "^2.8.5", 21 | "csurf": "^1.11.0", 22 | "dotenv": "^10.0.0", 23 | "express": "^4.17.1", 24 | "express-session": "^1.17.2", 25 | "helmet": "^4.6.0", 26 | "http-errors": "^1.8.1", 27 | "jsonwebtoken": "^8.5.1", 28 | "passport": "^0.5.0", 29 | "passport-jwt": "^4.0.0", 30 | "passport-local": "^1.0.0", 31 | "pg": "^8.7.1", 32 | "stripe": "^8.195.0", 33 | "swagger-client": "^3.18.4", 34 | "swagger-jsdoc": "^6.2.1", 35 | "swagger-parser": "^10.0.3", 36 | "swagger-ui": "^4.10.3", 37 | "swagger-ui-dist": "^4.10.3", 38 | "swagger-ui-express": "^4.3.0", 39 | "validator": "^13.7.0" 40 | }, 41 | "devDependencies": { 42 | "@faker-js/faker": "^6.1.1", 43 | "cross-env": "^7.0.3", 44 | "jest": "^27.4.3", 45 | "nodemon": "^2.0.15", 46 | "supertest": "^6.1.6", 47 | "supertest-session": "^4.1.0" 48 | }, 49 | "jest": { 50 | "testEnvironment": "node", 51 | "coveragePathIgnorePatterns": [ 52 | "/node_modules/" 53 | ] 54 | }, 55 | "engines": { 56 | "node": "14.x" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/testData/initTestData.sql: -------------------------------------------------------------------------------- 1 | -- *********************** TEST DATA *********************************** 2 | -- ************** Not currently being used !! ************************** 3 | -- ************* using initTestData.js instead ************************* 4 | 5 | INSERT INTO products (id, name, price, description, quantity, category) 6 | VALUES (1, 'T-Shirt', 19.99, 'Pima cotton unisex t-shirt', 1000, 'tops'), 7 | (2, 'Pants', 39.99, 'Fitted twill pants', 1000, 'bottoms'), 8 | (3, 'Jacket', 54.99, 'Structured jacket for layering', 1000, 'tops'); 9 | 10 | INSERT INTO carts (id, user_id) 11 | VALUES (1, 2); 12 | 13 | INSERT INTO cart_items (cart_id, product_id, quantity) 14 | VALUES (1, 1, 2), 15 | (1, 2, 3); 16 | 17 | INSERT INTO addresses (id, address1, address2, city, state, zip, country, user_id, first_name, last_name) 18 | VALUES (1, '643 Minna St', 'Apt 3', 'San Francisco', 'CA', '94103', 'United States', 1, 'Sam', 'Mister'), 19 | (2, '123 Easy St', 'Apt 1', 'Springfield', 'IL', '11111', 'United States', 1, 'Sam', 'Mister'), 20 | (3, '40 Main St', 'Apt 6', 'San Francisco', 'CA', '94103', 'United States', 2, 'Blue', 'Blur'), 21 | (4, '123 Easy St', null, 'San Francisco', 'CA', '94103', 'United States', 7, 'Pumpkim', 'Pie'); 22 | 23 | INSERT INTO cards (id, user_id, provider, card_no, card_type, cvv, billing_address_id, exp_month, exp_year) 24 | VALUES (1, 1, 'Mastercard', '5555555555554444', 'credit', '567', 1, 5, 2024), 25 | (1, 1, 'Visa', '4000056655665556', 'debit', '789', 1, 7, 2024), 26 | (1, 2, 'Mastercard', '5200828282828210', 'debit', '345', 3, 12, 2024), 27 | (1, 7, 'Visa', '4242424242424242', 'credit', '123', 492, 4, 2025); 28 | 29 | INSERT INTO orders (id, user_id, status, shipping_address_id, billing_address_id, payment_id, amount_charged, stripe_charge_id) 30 | VALUES (1, 1, 'pending', 1, 1, 1, 279.91, 'test'), 31 | (2, 1, 'pending', 1, 1, 2, 39.98, 'test'); 32 | 33 | INSERT INTO order_items (order_id, product_id, quantity) 34 | VALUES (1, 1, 2), 35 | (1, 2, 5), 36 | (2, 1, 2); 37 | 38 | UPDATE users 39 | SET primary_address_id = 4, primary_payment_id = 4, 40 | WHERE id = 7; -------------------------------------------------------------------------------- /services/productService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const Product = require('../models/ProductModel'); 3 | 4 | 5 | module.exports.getAll = async () => { 6 | try { 7 | const products = await Product.getAll(); 8 | 9 | // throw error if no products found 10 | if(!products) { 11 | throw httpError(404, 'No products.'); 12 | } 13 | 14 | return { products }; 15 | 16 | } catch(err) { 17 | throw err; 18 | } 19 | } 20 | 21 | module.exports.getById = async (id) => { 22 | try { 23 | const product = await Product.findById(id); 24 | 25 | // throw error if no product found 26 | if(!product) { 27 | throw httpError(404, 'Product not found.'); 28 | } 29 | 30 | return { product }; 31 | 32 | } catch(err) { 33 | throw err; 34 | } 35 | } 36 | 37 | module.exports.getCategory = async (category) => { 38 | try { 39 | const products = await Product.findByCategory(category); 40 | 41 | if(!products) { 42 | throw httpError(404, 'No products in category.'); 43 | } 44 | 45 | return { products }; 46 | 47 | } catch(err) { 48 | throw err; 49 | } 50 | } 51 | 52 | module.exports.getSearch = async (query) => { 53 | try { 54 | // return error if no queries 55 | if (!query) { 56 | throw httpError(400, 'Please enter search query.'); 57 | } 58 | 59 | // get all products 60 | var products = []; 61 | 62 | // remove products that do not match query 63 | for (var [key, value] of Object.entries(query)) { 64 | value = value.toLowerCase(); 65 | const queryResults = await Product.findByQuery(value); 66 | if (queryResults) { 67 | products = [...products, ...queryResults]; 68 | } 69 | } 70 | 71 | // return error if no products match query 72 | if(products.length === 0) { 73 | throw httpError(404, 'No results. Please try another search.'); 74 | } 75 | 76 | return { products }; 77 | 78 | } catch(err) { 79 | throw err; 80 | } 81 | } -------------------------------------------------------------------------------- /lib/formatUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions to wipe sensitive data from objects before returning 3 | * 4 | */ 5 | 6 | 7 | /** 8 | * Helper function to check that: 9 | * - wipes sensitive data, replacing chars with asterics 10 | * - formats expiry as MM/YYYY instead of Date object 11 | * 12 | * @param {Object} card the payment method to wipe 13 | */ 14 | module.exports.wipeCardData = card => { 15 | 16 | // format card_no as ************1234 17 | card.card_no = '************' + card.card_no.slice(-4); 18 | 19 | // format cvv as *** 20 | card.cvv = '***'; 21 | } 22 | 23 | /** 24 | * Helper function to wipe password data before returning 25 | * 26 | * @param {Object} user the user to wipe 27 | */ 28 | module.exports.wipePassword = user => { 29 | // delete password hash 30 | delete user.pw_hash; 31 | 32 | // delete password salt 33 | delete user.pw_salt; 34 | } 35 | 36 | 37 | /** 38 | * Helper function to check that: 39 | * - checks if address is the user's primary address 40 | * - attaches boolean property to address object, is_primary_address 41 | * 42 | * @param {Object} address the address object 43 | * @param {integer|null} primary_address_id the id of the user's primary address 44 | */ 45 | module.exports.attachIsPrimaryAddress = (address, primary_address_id) => { 46 | if (primary_address_id) { 47 | address.is_primary_address = address.id === primary_address_id; 48 | } else { 49 | // base case if primary_address_id is null 50 | address.is_primary_address = false; 51 | } 52 | } 53 | 54 | /** 55 | * Helper function to check that: 56 | * - checks if payment method is the user's primary payment method 57 | * - attaches boolean property to payment object, is_primary_payment 58 | * 59 | * @param {Object} payment the payment object 60 | * @param {integer|null} primary_payment_id the id of the user's primary payment method 61 | */ 62 | module.exports.attachIsPrimaryPayment = (payment, primary_payment_id) => { 63 | if (primary_payment_id) { 64 | payment.is_primary_payment = payment.id === primary_payment_id; 65 | } else { 66 | // base case if primary_payment_id is null 67 | payment.is_primary_payment = false; 68 | } 69 | } -------------------------------------------------------------------------------- /services/accountService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { genPassword } = require('../lib/customAuth/passwordUtils'); 3 | const { wipePassword } = require('../lib/formatUtils'); 4 | const { validateUser, 5 | validateName, 6 | validateEmail, 7 | validatePassword, 8 | validateID } = require('../lib/validatorUtils'); 9 | const User = require('../models/UserModel'); 10 | const Address = require('../models/AddressModel'); 11 | const Card = require('../models/CardModel'); 12 | 13 | module.exports.getAccount = async (user_id) => { 14 | try { 15 | // validate inputs 16 | validateID(user_id); 17 | 18 | // check if user exists 19 | const user = await validateUser(user_id); 20 | 21 | // wipe password info before returning 22 | wipePassword(user); 23 | 24 | return { user }; 25 | 26 | } catch(err) { 27 | throw (err); 28 | } 29 | } 30 | 31 | module.exports.putAccount = async (data) => { 32 | try { 33 | // validate user id 34 | validateID(data.user_id); 35 | 36 | // check if user exists 37 | const user = await validateUser(data.user_id); 38 | 39 | // validate properties in data and modify user 40 | for (const property in data) { 41 | if (property === "first_name" || property === "last_name") { 42 | validateName(data[property]); 43 | user[property] = data[property]; 44 | } else if (property === "email") { 45 | validateEmail(data[property]); 46 | user[property] = data[property]; 47 | } else if (property === "password") { 48 | validatePassword(data[property]); 49 | // hash and salt password 50 | const pwObj = await genPassword(data[property]); 51 | 52 | // save hash and salt to db, not password itself 53 | user.pw_hash = pwObj.hash; 54 | user.pw_salt = pwObj.salt; 55 | } 56 | } 57 | 58 | // update the user 59 | const updatedUser = await User.update(user); 60 | 61 | // wipe password info before returning 62 | wipePassword(updatedUser); 63 | 64 | // return updated user; 65 | return { user: updatedUser }; 66 | 67 | } catch(err) { 68 | throw err; 69 | } 70 | } -------------------------------------------------------------------------------- /lib/customAuth/genKeyPair.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | require('dotenv').config(); 5 | 6 | function genKeyPair() { 7 | 8 | // only create new keys if needed 9 | if (!process.env.PUB_KEY || !process.env.PRIV_KEY) { 10 | 11 | // generates a Buffer obj where the keys are stored 12 | const keyPair = crypto.generateKeyPairSync('rsa', { 13 | modulusLength: 4096, // standard for RSA keys 14 | publicKeyEncoding: { 15 | type: 'pkcs1', // Public Key Cryptography Standards 1 16 | format: 'pem' // Most common choice 17 | }, 18 | privateKeyEncoding: { 19 | type: 'pkcs1', 20 | format: 'pem' 21 | } 22 | }); 23 | 24 | /** 25 | * NOTE: Cannot write .pem file directly to .env because of line breaks in .pem 26 | * But, new line breaks are needed for functions to read keys 27 | * 28 | * So, encode key as base64 so key is single line 29 | * Decode back to utf8 when using in files 30 | */ 31 | 32 | // convert to base64 so key is one line 33 | const singleLinePubKey = Buffer.from(keyPair.publicKey).toString('base64'); 34 | const singleLinePrivKey = Buffer.from(keyPair.privateKey).toString('base64'); 35 | 36 | // path of .env file 37 | const envPath = path.resolve(__dirname, '../..', '.env'); 38 | 39 | // add header comment to new section in .env file 40 | fs.appendFile(envPath,`\n# RSA Key Pair\n`, (err) => { 41 | if (err) { 42 | console.error(err.stack); 43 | } 44 | }); 45 | 46 | // write public key to .env file 47 | fs.appendFile(envPath, `PUB_KEY=\"` + singleLinePubKey + `\"\n`, (err) => { 48 | if (err) { 49 | console.error(err.stack); 50 | } else { 51 | console.log('Added PUB_KEY to .env') 52 | } 53 | }); 54 | 55 | // write private key to .env file 56 | fs.appendFile(envPath, `PRIV_KEY=\"` + singleLinePrivKey + `\"\n`, (err) => { 57 | if (err) { 58 | console.error(err.stack); 59 | } else { 60 | console.log('Added PRIV_KEY to .env') 61 | } 62 | }); 63 | } 64 | } 65 | 66 | genKeyPair(); -------------------------------------------------------------------------------- /routes/checkout/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authRouter = require('./auth'); 3 | const shippingRouter = require('./shipping'); 4 | const paymentRouter = require('./payment'); 5 | const orderRouter = require('./order'); 6 | const { getCart } = require('../../services/cartService'); 7 | const { demiAuth } = require('../../lib/customAuth/jwtAuth'); 8 | 9 | module.exports = (app) => { 10 | 11 | app.use('/checkout', router); 12 | 13 | /** 14 | * @swagger 15 | * /checkout: 16 | * get: 17 | * tags: 18 | * - Checkout 19 | * summary: Begins checkout flow 20 | * parameters: 21 | * - name: cart_id 22 | * description: ID associated with Cart 23 | * in: cookie 24 | * required: true 25 | * schema: 26 | * $ref: '#/components/schemas/id' 27 | * responses: 28 | * 302: 29 | * description: | 30 | * Redirects to /checkout/auth if user not authenticated. 31 | * Redirects to /checkout/shipping if user is authenticated. 32 | * 400: 33 | * $ref: '#/components/responses/InputsError' 34 | * 404: 35 | * description: Cart associated with cart_id doesn't exist or is empty. 36 | */ 37 | router.get('/', demiAuth, async (req, res ,next) => { 38 | try { 39 | // grab cart_id from express session, if it exists 40 | const cart_id = req.session.cart_id || null; 41 | 42 | // getCart to check if cart is empty 43 | const cart = await getCart(cart_id); 44 | 45 | // grab user_id from json web token 46 | const user_id = req.jwt ? req.jwt.sub : null; 47 | 48 | if (user_id) { 49 | // if user is signed in, skip sign in, redirect to shipping 50 | res.redirect('/checkout/shipping'); 51 | } else { 52 | // redirect to sign in 53 | res.redirect('/checkout/auth'); 54 | } 55 | } catch(err) { 56 | next(err); 57 | } 58 | }); 59 | 60 | // extend route to auth (login & register) 61 | authRouter(router); 62 | 63 | // extend route to shipping processing 64 | shippingRouter(router); 65 | 66 | // extend route to payment & billing processing 67 | paymentRouter(router); 68 | 69 | // extend route to order processing & confirmation 70 | orderRouter(router); 71 | } -------------------------------------------------------------------------------- /app/swagger.js: -------------------------------------------------------------------------------- 1 | const swaggerJSDoc = require('swagger-jsdoc'); 2 | const swaggerUi = require('swagger-ui-express'); 3 | require('dotenv').config(); 4 | 5 | module.exports = async (app) => { 6 | 7 | // swagger definition 8 | const swaggerDefinition = { 9 | openapi: '3.0.0', 10 | info: { 11 | title: 'E-Commerce API', 12 | version: '1.0.0', 13 | description: 'Basic e-commerce API with express and postgres', 14 | }, 15 | servers: [ 16 | { 17 | url: `https://crk-e-commerce.herokuapp.com`, 18 | description: 'Heroku production server' 19 | }, { 20 | url: `http://localhost:${process.env.PORT}`, 21 | description: 'Development server' 22 | } 23 | ], 24 | components: { 25 | securitySchemes: { 26 | bearerJWT: { 27 | type: 'apiKey', 28 | scheme: 'bearer', 29 | bearerFormat: 'JWT' 30 | }, 31 | cookieJWT: { 32 | type: 'apiKey', 33 | in: 'cookie', 34 | name: 'access_token' 35 | } 36 | } 37 | }, 38 | tags: [ 39 | 'Shop', 40 | 'Auth', 41 | 'Account', 42 | 'Checkout' 43 | ] 44 | }; 45 | 46 | // options for the swagger docs 47 | const options = { 48 | // import swaggerDefinitions 49 | swaggerDefinition: swaggerDefinition, 50 | // path to the API docs 51 | apis: [ './routes/shop/home.js', 52 | './routes/shop/products.js', 53 | './routes/shop/*.js', 54 | './routes/auth/register.js', 55 | './routes/auth/*.js', 56 | './routes/account/index.js', 57 | './routes/account/*.js', 58 | './routes/checkout/index.js', 59 | './routes/checkout/auth.js', 60 | './routes/checkout/shipping.js', 61 | './routes/checkout/payment.js', 62 | './routes/checkout/*.js'], 63 | }; 64 | 65 | // initialize swagger-jsdoc 66 | const swaggerSpec = swaggerJSDoc(options); 67 | 68 | // serve swagger,json 69 | app.get('/swagger.json', (req, res) => { 70 | res.setHeader('Content-Type', 'application/json'); 71 | res.send(swaggerSpec); 72 | }); 73 | 74 | // serve swagger UI 75 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); 76 | 77 | } -------------------------------------------------------------------------------- /tests/testData/removeTestData.js: -------------------------------------------------------------------------------- 1 | const db = require('../../db'); 2 | const { users, 3 | products, 4 | carts, 5 | cart_items, 6 | addresses, 7 | cards, 8 | orders, 9 | order_items } = require('./index'); 10 | 11 | /** 12 | * Function to remove test data from database 13 | * 14 | */ 15 | const removeTestData = async () => { 16 | console.log('Removing test data. Please wait...'); 17 | 18 | /** 19 | * First, update primary address and primary payment of user7 to enable 20 | * address and payment method to be deleted. 21 | */ 22 | 23 | // pg statement 24 | let statement = `UPDATE users 25 | SET primary_address_id = $1, primary_payment_id = $2 26 | WHERE id=${users.user7.id};`; 27 | 28 | // pg values 29 | let values = [ null, null ]; 30 | 31 | try { 32 | // make query 33 | await db.query(statement, values); 34 | } catch(e) {} 35 | 36 | /** 37 | * Now delete rest of data 38 | * Order is important! 39 | */ 40 | const testData = { 41 | order_items, 42 | orders, 43 | cards, 44 | addresses, 45 | cart_items, 46 | carts, 47 | products, 48 | users, 49 | } 50 | 51 | for (const table in testData) { 52 | 53 | for (const row in testData[table]) { 54 | // open pg statement 55 | statement = `DELETE FROM ${table} WHERE `; 56 | // instantiate counter 57 | let i = 1; 58 | // instantiate array of values 59 | values = []; 60 | 61 | // iterate through columns and add to statement and values 62 | for (const col in testData[table][row]) { 63 | // skip certain user properties 64 | if (col !== 'password' && col !== 'primary_address_id' && col!=='primary_payment_id'){ 65 | statement+=`${col} = $${i} AND `; 66 | i++; 67 | values.push(testData[table][row][col]); 68 | } 69 | } 70 | 71 | // remove last AND and space from statement 72 | statement = statement.substring(0, statement.length - 5); 73 | 74 | // make query 75 | try { 76 | await db.query(statement, values); 77 | } catch(e) { 78 | break; 79 | } 80 | } 81 | } 82 | 83 | console.log('Test data removed from database.') 84 | } 85 | 86 | // run function 87 | removeTestData(); -------------------------------------------------------------------------------- /tests/products.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const session = require('supertest-session'); 3 | const { product } = require('./testData'); 4 | 5 | describe ('Product endpoints', () => { 6 | 7 | describe('GET \'/products\'', () => { 8 | 9 | it ('should return products page', async () => { 10 | const res = await session(app) 11 | .get('/products') 12 | .set('Accept', 'application/json') 13 | .expect('Content-Type', /json/) 14 | .expect(200); 15 | expect(res.body).toBeDefined(); 16 | expect(res.body.products).toBeDefined(); 17 | }) 18 | }) 19 | 20 | describe('GET \'/products/search?q=cotton\'', () => { 21 | 22 | it ('should return products by category', async () => { 23 | const query = 'cotton'; 24 | const res = await session(app) 25 | .get(`/products/search?q=${query}`) 26 | .set('Accept', 'application/json') 27 | .expect('Content-Type', /json/) 28 | .expect(200); 29 | expect(res.body).toBeDefined(); 30 | expect(res.body.products).toBeDefined(); 31 | expect(res.body.products[0]).toBeDefined(); 32 | expect(res.body.products[0].description).toBeDefined(); 33 | expect(res.body.products[0].description).toContain(query); 34 | }) 35 | }) 36 | 37 | describe('GET \'/products/:product_id\'', () => { 38 | 39 | it ('should return a product by id', async () => { 40 | const res = await session(app) 41 | .get(`/products/${product.product_id}`) 42 | .set('Accept', 'application/json') 43 | .expect('Content-Type', /json/) 44 | .expect(200); 45 | expect(res.body).toBeDefined(); 46 | expect(res.body.product).toBeDefined(); 47 | expect(res.body.product.id).toEqual(product.product_id); 48 | }) 49 | }) 50 | 51 | describe('GET \'/products/category/:category\'', () => { 52 | 53 | it ('should return products by category', async () => { 54 | const category = 'tops'; 55 | const res = await session(app) 56 | .get(`/products/category/${category}`) 57 | .set('Accept', 'application/json') 58 | .expect('Content-Type', /json/) 59 | .expect(200); 60 | expect(res.body).toBeDefined(); 61 | expect(res.body.products).toBeDefined(); 62 | expect(res.body.products[0].category).toEqual(category); 63 | }) 64 | }) 65 | }) -------------------------------------------------------------------------------- /services/cartService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const Cart = require('../models/CartModel'); 3 | const CartItem = require('../models/CartItemModel'); 4 | const { validateID, 5 | validateNullableID } = require('../lib/validatorUtils'); 6 | 7 | module.exports.postCart = async (user_id) => { 8 | try { 9 | // validate inputs 10 | validateNullableID(user_id); 11 | 12 | // create a new cart 13 | const cart = await Cart.create(user_id); 14 | 15 | // check that cart was created 16 | if (!cart) { 17 | throw httpError(500, 'Server error creating cart.'); 18 | } 19 | 20 | return { 21 | cart: cart, 22 | cartItems: [] 23 | } 24 | } catch(err) { 25 | throw err; 26 | } 27 | } 28 | 29 | module.exports.getCart = async (cart_id, user_id) => { 30 | try { 31 | let cart; 32 | 33 | if (cart_id === null) { 34 | if (user_id === null) { 35 | throw httpError(404, 'Cart not found.') 36 | } else { 37 | // validate inputs 38 | validateID(user_id) 39 | 40 | // find cart by user_id 41 | cart = await Cart.findByUserId(user_id); 42 | } 43 | } else { 44 | // validate inputs 45 | validateID(cart_id); 46 | 47 | // find cart by on cart_id 48 | cart = await Cart.findById(cart_id); 49 | } 50 | 51 | // if no cart found 52 | if (!cart) { 53 | throw httpError(404, 'Cart not found.') 54 | } 55 | 56 | // find all items in cart 57 | const cartItems = await CartItem.findInCart(cart_id); 58 | 59 | if (!cartItems) { 60 | throw httpError(404, 'Cart empty.') 61 | } 62 | 63 | return { 64 | cart: cart, 65 | cartItems: cartItems 66 | } 67 | } catch(err) { 68 | throw err; 69 | } 70 | } 71 | 72 | module.exports.deleteCart = async (cart_id) => { 73 | try { 74 | // validate inputs 75 | validateID(cart_id); 76 | 77 | // verify cart is empty 78 | const cartItems = await CartItem.findInCart(cart_id); 79 | if (cartItems) { 80 | throw httpError(405, 'Cart not empty.') 81 | } 82 | 83 | // delete cart by cart_id 84 | const cart = await Cart.delete(cart_id); 85 | 86 | // if no cart found 87 | if (!cart) { 88 | throw httpError(404, 'Cart not found.') 89 | } 90 | 91 | return { 92 | cart: cart, 93 | cart_items: [] 94 | } 95 | } catch(err) { 96 | throw err; 97 | } 98 | } -------------------------------------------------------------------------------- /services/authService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { attachJWT } = require('../lib/customAuth/attachJWT'); 3 | const { genPassword, validPassword } = require('../lib/customAuth/passwordUtils'); 4 | const { wipePassword } = require('../lib/formatUtils'); 5 | const { validateAuthInputs } = require('../lib/validatorUtils'); 6 | const cartConsolidator = require('../lib/cartConsolidator'); 7 | const User = require('../models/UserModel'); 8 | 9 | module.exports.register = async (data) => { 10 | try { 11 | // check for required inputs 12 | validateAuthInputs(data); 13 | 14 | // pwObj contains salt and hash generated 15 | const pwObj = await genPassword(data.password); 16 | 17 | // check if user already exists 18 | const user = await User.findByEmail(data.email); 19 | if (user) { 20 | throw httpError(409, 'Email already in use'); 21 | }; 22 | 23 | // create new user 24 | const newUser = await User.create({ 25 | email: data.email, 26 | salt: pwObj.salt, 27 | hash: pwObj.hash 28 | }); 29 | 30 | // handle if user had shopping cart before registering in 31 | const { cart, cartItems } = await cartConsolidator(data.cart_id, newUser.id); 32 | 33 | // wipe password info before returning 34 | wipePassword(newUser) 35 | 36 | // attach JWT and return user 37 | const response = attachJWT(newUser); 38 | 39 | return { 40 | ...response, 41 | cart, 42 | cartItems 43 | } 44 | 45 | } catch(err) { 46 | throw err; 47 | }; 48 | }; 49 | 50 | module.exports.login = async (data) => { 51 | try { 52 | // check for required inputs 53 | validateAuthInputs(data); 54 | 55 | // check if user already exists 56 | const user = await User.findByEmail(data.email); 57 | if (!user) { 58 | throw httpError(401, 'Incorrect email or password.'); 59 | }; 60 | 61 | // validate password 62 | const isValid = await validPassword(data.password, user.pw_hash, user.pw_salt); 63 | if (!isValid) { 64 | throw httpError(401, 'Incorrect email or password.'); 65 | } 66 | 67 | // handle if user had shopping cart before logging in 68 | const { cart, cartItems } = await cartConsolidator(data.cart_id, user.id); 69 | 70 | // wipe password info before returning 71 | wipePassword(user); 72 | 73 | // attach JWT and return user 74 | const response = attachJWT(user); 75 | 76 | return { 77 | ...response, 78 | cart, 79 | cartItems 80 | } 81 | 82 | } catch(err) { 83 | throw err; 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /services/cartItemService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { validateCartItem, 3 | validateCartItemInputs } = require('../lib/validatorUtils'); 4 | const { postCart } = require('./cartService'); 5 | const CartItem = require('../models/CartItemModel'); 6 | const Cart = require('../models/CartModel'); 7 | const Product = require('../models/ProductModel'); 8 | 9 | module.exports.postCartItem = async (data) => { 10 | try { 11 | // throw error if inputs invalid 12 | validateCartItemInputs(data); 13 | 14 | // check that product exists 15 | const product = await Product.findById(data.product_id); 16 | if(!product) { 17 | throw httpError(404, 'Product does not exist'); 18 | } 19 | 20 | // create a cart if one does not exist 21 | if (!data.cart_id) { 22 | const { cart, cartItems } = await postCart(data.user_id); 23 | data.cart_id = cart.id; 24 | } 25 | 26 | // grab cartItem, if it already exists in cart 27 | let cartItem = await CartItem.findOne(data); 28 | 29 | if (cartItem) { 30 | // if item already in cart, update quantity 31 | let updatedQuantity = cartItem.quantity + data.quantity; 32 | cartItem = await CartItem.update({ ...data, quantity: updatedQuantity }); 33 | } else { 34 | // otherwise, create new cart item 35 | cartItem = await CartItem.create(data); 36 | } 37 | 38 | return { cartItem }; 39 | } catch(err) { 40 | throw err; 41 | } 42 | } 43 | 44 | module.exports.getCartItem = async (data) => { 45 | try { 46 | // validate inputs and grab cart 47 | const cartItem = await validateCartItem(data); 48 | 49 | return { cartItem }; 50 | } catch(err) { 51 | throw err; 52 | } 53 | } 54 | 55 | module.exports.putCartItem = async (data) => { 56 | try { 57 | // validate inputs 58 | await validateCartItem(data); 59 | 60 | // update quantity of item in cart 61 | const cartItem = await CartItem.update(data); 62 | 63 | return { cartItem }; 64 | } catch(err) { 65 | throw err; 66 | } 67 | } 68 | 69 | module.exports.deleteCartItem = async (data) => { 70 | try { 71 | // validate inputs 72 | await validateCartItem(data); 73 | 74 | // delete cart item and return it 75 | const cartItem = await CartItem.delete(data); 76 | 77 | // check if cart is empty 78 | const remainingItems = await CartItem.findInCart(data.cart_id); 79 | 80 | if(!remainingItems) { 81 | await Cart.delete(data.cart_id); 82 | cartItem.cart_id = null; 83 | } 84 | 85 | return { cartItem }; 86 | } catch(err) { 87 | throw err; 88 | } 89 | } -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # E-Commerce API Test Suite 2 | 3 | 4 | ## Setup 5 | ###### You must set up the server before running tests. 6 | To run locally, first install node_modules and generate RSA Key Pair: 7 | 8 | ``` 9 | npm install 10 | ``` 11 | Will also run `install ` script of `package.json`, which will generate an RSA key pair in a `.env` file. 12 | 13 | Open a PostgreSQL database of your choice. Schema with tables is located in `db/init.sql`. E.g., generate tables by running: 14 | ``` 15 | cd db 16 | cat init.sql | psql -h [PGHOST] -U [PGUSER] -d [PGDATABASE] -w [PGPASSWORD] 17 | ``` 18 | Where 'PGHOST', 'PGUSER', 'PGDATABASE', and 'PGPASSWORD' are your respective Postgres host, user, database, and password values. 19 | 20 | Add the following fields with respective values to the `.env` file: 21 | 22 | ``` 23 | # Postgres Database 24 | PGHOST= 25 | PGUSER= 26 | PGDATABASE= 27 | PGPASSWORD= 28 | PGPORT= 29 | 30 | # Express server 31 | PORT= 32 | SESSION_SECRET= 33 | 34 | # Node.js 35 | NODE_ENV= 36 | 37 | # Stripe key pair 38 | STRIPE_PUBLIC_KEY= 39 | STRIPE_SECRET_KEY= 40 | ``` 41 | Create an account with Stripe to generate a key pair. 42 | Can use a test key pair for development that will not charge cards. 43 | 44 | Then run the app: 45 | 46 | ``` 47 | node index.js 48 | ``` 49 | 50 | 51 | ## Running Tests 52 | To run tests, make sure database is set up (run `db/init.sql`) and `.env` contains is completed as described above. 53 | 54 | Once test data is added, run: 55 | ``` 56 | npm test 57 | ``` 58 | *Note*: `npm test` script will also run `npm run pretest` and `npm run posttest` which include scripts for setting up and tearing down test data in database. 59 | 60 | ### Pre Test 61 | The `pretest` npm script will add specific test data to your database before tests are run. It will not effect any other data in your database. 62 | 63 | ### Post Test 64 | The `posttest` npm script will remove the test data from your database after tests are run. It will not remove other data from your database. 65 | 66 | If your tests fail and the `posttest` script fails to run, you can call it manually `npm run posttest` or calling the file directly with `node tests/testData/removeTestData.js`. 67 | 68 | If you want to remove all data from your database and don't want to have to do it manually, I wrote a file for that. Run `node tests/testData/removeAllData.js`. 69 | 70 | 71 | ## FAQ 72 | 73 | ###### Tests failing on initial run after setting up DB 74 | If your tests fail on the first run, don't dispair. This happens frequently when used on a fresh DB, I don't know why. If you have any idea as to why, please email me @ carolynkrny@gmail.com. Clear the test data from the db (`npm run posttest`) and run the test suite again until it passes. 75 | 76 | ###### Why are some E2E tests randomly failing? 77 | Sometimes one or more of the E2E checkout tests will fail on a particular run even though every other test passes. I don't know why, something must overflow and these are the longest tests. Clear the test data from the db (`npm run posttest`) and then run the test suite again and they will likely pass. 78 | 79 | -------------------------------------------------------------------------------- /lib/cartConsolidator.js: -------------------------------------------------------------------------------- 1 | const Cart = require('../models/CartModel'); 2 | const CartItem = require('../models/CartItemModel'); 3 | 4 | /** 5 | * Helper function for authService 6 | * Roll over existing cart on register or login 7 | * 8 | * @param {number|null} newCartId id of cart prior to register/login to be consolidated 9 | * @param {number} userId id of the user 10 | * 11 | * @return {Object} contains cart and cartItems assocaited with user 12 | */ 13 | module.exports = async (newCartId, userId) => { 14 | 15 | try { 16 | // determine if user has created a shopping cart before register/login 17 | if (newCartId) { 18 | // check if user already has an old cart from previous login 19 | const oldCart = await Cart.findByUserId(userId); 20 | 21 | // if two carts, combine carts 22 | if (oldCart) { 23 | 24 | // grab items in old cart 25 | const oldCartItems = await CartItem.findInCart(oldCart.id); 26 | 27 | // grab items in new cart 28 | const newCartItems = await CartItem.findInCart(newCartId); 29 | 30 | // check that there are items in old cart (skip if null) 31 | if (oldCartItems) { 32 | 33 | // iterate through items in old cart 34 | for (const cartItem of oldCartItems) { 35 | 36 | // check if same item is already in old cart 37 | const sameItem = newCartItems.find(newItem => newItem.product_id === cartItem.product_id); 38 | 39 | if (sameItem) { 40 | // update quantity in new cart 41 | const updatedQuantity = cartItem.quantity + sameItem.quantity; 42 | await CartItem.update({ ...sameItem, quantity: updatedQuantity }); 43 | } else { 44 | // add item to new cart 45 | await CartItem.create({ ...cartItem, cart_id: newCartId }); 46 | } 47 | 48 | // delete item from new cart 49 | await CartItem.delete({ ...cartItem }); 50 | } 51 | } 52 | // delete old cart 53 | await Cart.delete(oldCart.id); 54 | } 55 | // update user's new shopping cart with user's id 56 | const cart = await Cart.update({ id: newCartId, user_id: userId }); 57 | 58 | // grab items in cart 59 | const cartItems = await CartItem.findInCart(newCartId); 60 | 61 | // return id of new cart 62 | return { 63 | cart, 64 | cartItems 65 | }; 66 | } else { 67 | // check if user has a cart from a previous session 68 | const oldCart = await Cart.findByUserId(userId); 69 | 70 | // if two carts, combine carts 71 | if (oldCart) { 72 | 73 | // grab items in cart 74 | const cartItems = await CartItem.findInCart(oldCart.id); 75 | 76 | return { 77 | cart: oldCart, 78 | cartItems: cartItems 79 | } 80 | } else { 81 | // return empty cart 82 | return { 83 | cart: null, 84 | cartItems: [] 85 | }; 86 | } 87 | } 88 | } catch(err) { 89 | throw err; 90 | } 91 | } -------------------------------------------------------------------------------- /app/passport.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const LocalStrategy = require('passport-local').Strategy; 3 | const validPassword = require('../lib/customAuth/passwordUtils').validPassword; 4 | const JWTStrategy = require('passport-jwt').Strategy; 5 | const ExtractJWT = require('passport-jwt').ExtractJwt; 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const pathToKey = path.join(__dirname, '..', 'pub_key.pem'); 9 | const User = require('../models/UserModel'); 10 | 11 | require('dotenv').config(); 12 | 13 | const isProduction = process.env.NODE_ENV === 'production'; 14 | const PUB_KEY = isProduction ? process.env.PUB_KEY : fs.readFileSync(pathToKey, 'utf8'); 15 | 16 | /** 17 | * Passport authentication middleware 18 | * implementations of local strategy and jwt strategy below. 19 | * 20 | * NOT CURRENTLY BEING USED, developed as a learning exercise 21 | * 22 | */ 23 | 24 | module.exports = async (app) => { 25 | 26 | // connect express app to passport 27 | app.use(passport.initialize()); 28 | 29 | // -- START LOCAL STRATEGY SECTION -- 30 | 31 | // initialize session for local strategy 32 | app.use(passport.session()); 33 | 34 | // field names from HTML form that passport should look for in JSON 35 | const fieldNames = { 36 | usernameField: 'email', 37 | passwrdField: 'password' 38 | }; 39 | 40 | // use local strategy for initial login 41 | passport.use(new LocalStrategy(fieldNames, async (username, password, done) => { 42 | try { 43 | // find user in database, if one exists 44 | const user = await User.findByEmail(username); 45 | 46 | // if no user, tell passport to be done 47 | if(!user) return done(null, false); 48 | 49 | // validate user password 50 | const isValid = validPassword(password, user.pw_hash, user.pw_salt); 51 | 52 | if (isValid) { 53 | return done(null, user); 54 | } else { 55 | return done(null, false); 56 | }; 57 | } catch(err) { 58 | done(err); 59 | }; 60 | })); 61 | 62 | // put user id into the session 63 | passport.serializeUser((user, done) => { 64 | done(null, user.id); 65 | }); 66 | 67 | // take user id out of the session 68 | passport.deserializeUser(async (userId, done) => { 69 | try { 70 | const user = await User.findById(userId); 71 | done(null, user); 72 | } catch(err) { 73 | done(err); 74 | } 75 | }); 76 | 77 | // -- END LOCAL STRATEGY SECTION -- 78 | 79 | 80 | 81 | // -- START JWT STRATEGY SECTION -- 82 | 83 | // define options for how token is extracted and verified 84 | const options = { 85 | jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), 86 | secretOrKey: PUB_KEY, 87 | algorithms: ['RS256'] 88 | }; 89 | 90 | // use JWT strategy for returning users 91 | passport.use(new JWTStrategy(options, async (payload, done) => { 92 | try { 93 | 94 | // find user in database, if one exists 95 | const user = await User.findById(payload.sub); 96 | 97 | // if there is a user, return user; else return false 98 | if(user) { 99 | return done(null, user); 100 | } else { 101 | return done(null, false); 102 | }; 103 | } catch(err) { 104 | done(err); 105 | }; 106 | })); 107 | 108 | // -- END JWT STRATEGY SECTION -- 109 | 110 | return passport; 111 | }; -------------------------------------------------------------------------------- /tests/orders.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const session = require('supertest-session'); 3 | const { loginUser, 4 | createCSRFToken } = require('./testUtils'); 5 | const { user1 } = require('./testData').users; 6 | const { order1 } = require('./testData').orders; 7 | 8 | describe ('Orders endpoints', () => { 9 | 10 | let testSession; 11 | let csrfToken; 12 | 13 | describe('Valid auth', () => { 14 | 15 | beforeAll(async () => { 16 | try { 17 | // create test session 18 | testSession = session(app); 19 | 20 | // create csrf token 21 | csrfToken = await createCSRFToken(testSession); 22 | 23 | // log user in 24 | await loginUser(user1, testSession, csrfToken); 25 | } catch(e) { 26 | console.log(e); 27 | } 28 | }) 29 | 30 | describe('GET \'/account/orders/all\'', () => { 31 | 32 | it ('Should return orders info', async () => { 33 | const res = await testSession 34 | .get('/account/orders/all') 35 | .set('Accept', 'application/json') 36 | .expect('Content-Type', /json/) 37 | .expect(200); 38 | expect(res.body).toBeDefined(); 39 | expect(res.body.orders).toBeDefined(); 40 | expect(res.body.orders.length).toBeGreaterThanOrEqual(1); 41 | }) 42 | }) 43 | 44 | describe('GET \'/account/orders/:order_id\'', () => { 45 | 46 | it ('Should return order info', async () => { 47 | const res = await testSession 48 | .get(`/account/orders/${order1.id}`) 49 | .set('Accept', 'application/json') 50 | .expect('Content-Type', /json/) 51 | .expect(200); 52 | expect(res.body).toBeDefined(); 53 | expect(res.body.order).toBeDefined(); 54 | expect(res.body.orderItems).toBeDefined(); 55 | expect(res.body.order.id).toEqual(order1.id); 56 | }) 57 | }) 58 | }) 59 | 60 | describe('Invalid auth', () => { 61 | 62 | beforeAll(async () => { 63 | try { 64 | // create test session 65 | testSession = session(app); 66 | 67 | // create csrf token 68 | csrfToken = await createCSRFToken(testSession); 69 | } catch(e) { 70 | console.log(e); 71 | } 72 | }) 73 | 74 | describe('GET \'/account/orders/all\'', () => { 75 | 76 | it ('Should return 401 error', (done) => { 77 | testSession 78 | .get('/account/orders/all') 79 | .set('Accept', 'application/json') 80 | .expect(401) 81 | .end((err, res) => { 82 | if (err) return done(err); 83 | return done(); 84 | }); 85 | }) 86 | }) 87 | 88 | describe('GET \'/account/orders/:order_id\'', () => { 89 | 90 | it ('Should return 401 error', (done) => { 91 | testSession 92 | .get('/account/orders/7') 93 | .set('Accept', 'application/json') 94 | .expect(401) 95 | .end((err, res) => { 96 | if (err) return done(err); 97 | return done(); 98 | }); 99 | }) 100 | }) 101 | }) 102 | }) -------------------------------------------------------------------------------- /services/checkoutService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { postAddress, getAddress } = require('./addressService'); 3 | const { postPayment, getPayment } = require('./paymentService'); 4 | const { postOrder } = require('./orderService'); 5 | const { getCart } = require('./cartService'); 6 | const { validateID } = require('../lib/validatorUtils'); 7 | 8 | module.exports.postShipping = async (data) => { 9 | try { 10 | // fetch address if it already exists 11 | if (data.address_id) { 12 | shipping = await getAddress(data); 13 | } else { 14 | // otherwise create new address for shipping 15 | shipping = await postAddress(data); 16 | } 17 | 18 | return { shipping: shipping.address }; 19 | 20 | } catch(err) { 21 | throw err; 22 | } 23 | } 24 | 25 | module.exports.postPayment = async (data) => { 26 | try { 27 | 28 | // -----------Handle Billing Address---------------- 29 | 30 | var billing = null; 31 | 32 | // fetch address if it already exists 33 | if (data.address_id) { 34 | billing = await getAddress(data); 35 | } else { 36 | // otherwise create new address for billing 37 | billing = await postAddress(data); 38 | } 39 | 40 | // -------------Handle Payment Method---------------- 41 | 42 | var payment = null; 43 | 44 | // fetch payment if it already exists 45 | if (data.payment_id) { 46 | payment = await getPayment(data); 47 | } else { 48 | // otherwise create new payment method 49 | payment = await postPayment({ 50 | ...data, 51 | billing_address_id: billing.address.id 52 | }); 53 | } 54 | 55 | // -----------------Return data---------------------- 56 | 57 | return { 58 | billing: billing.address, 59 | payment: payment.payment 60 | }; 61 | 62 | } catch(err) { 63 | throw err; 64 | } 65 | } 66 | 67 | module.exports.getCheckoutReview = async (data) => { 68 | try { 69 | // check for valid inputs 70 | validateID(data.user_id); 71 | validateID(data.cart_id); 72 | validateID(data.shipping_address_id); 73 | validateID(data.billing_address_id); 74 | validateID(data.payment_id); 75 | 76 | // get cart 77 | const { cart, cartItems } = await getCart(data.cart_id); 78 | 79 | // get shipping address info 80 | const shipping = await getAddress({ user_id: data.user_id, address_id: data.shipping_address_id }); 81 | 82 | // get billing address info 83 | const billing = await getAddress({ user_id: data.user_id, address_id: data.billing_address_id }); 84 | 85 | // get payment info 86 | const { payment } = await getPayment({ user_id: data.user_id, payment_id: data.payment_id }); 87 | 88 | return { 89 | cart, 90 | cartItems, 91 | shipping: shipping.address, 92 | billing: billing.address, 93 | payment 94 | }; 95 | 96 | } catch(err) { 97 | throw err; 98 | } 99 | } 100 | 101 | module.exports.postCheckout = async(data) => { 102 | try { 103 | // get info for checkout 104 | const checkout = await module.exports.getCheckoutReview(data); 105 | 106 | // attach user_id 107 | checkout.user_id = data.user_id; 108 | 109 | // create order 110 | return postOrder(checkout); 111 | 112 | } catch(err) { 113 | throw err; 114 | } 115 | } -------------------------------------------------------------------------------- /lib/customAuth/jwtAuth.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const jwt = require('jsonwebtoken'); 3 | require('dotenv').config(); 4 | const PUB_KEY = Buffer.from(process.env.PUB_KEY, 'base64').toString('utf8'); 5 | 6 | /** 7 | * CUSTOM JWT AUTH FUNCTIONS 8 | * 9 | * Functions handle authentication the same way, verifying encryption from ./attachJWT 10 | * 11 | * Functions handle failed authentication differently based on needs 12 | * 13 | */ 14 | 15 | //-------------------------------------------------------------------------------------------------------- 16 | 17 | /** 18 | * Helper function to verify JWT based on encryption from ./attachJWT 19 | * 20 | */ 21 | const verify = (token) => { 22 | return jwt.verify(token, PUB_KEY, { algorithms: ['RS256'] }); 23 | } 24 | 25 | 26 | /** 27 | * Helper auth function to handle JWT extraction and verification logic 28 | * 29 | * Can authenticate both: 30 | * - JWT Bearer tokens 31 | * - JWT in a cookie called 'access token' 32 | * 33 | * @param callback a callback function to be called if the JWT is missing 34 | */ 35 | const jwtAuth = async (req, res, next, callback) => { 36 | // split header which comes in format "Bearer eyJhbGciOiJ...." 37 | const headerParts = req.headers.authorization ? req.headers.authorization.split(' ') : [null]; 38 | 39 | // grab token from cookie, if it exists 40 | const tokenInCookie = req.cookies.access_token || null; 41 | 42 | try { 43 | // check token in cookie 44 | if (tokenInCookie) { 45 | // verify JWT and attach to req 46 | req.jwt = verify(tokenInCookie); 47 | next(); 48 | 49 | // check if header is in correct format 50 | } else if (headerParts[0] === 'Bearer' && headerParts[1].match(/\S+\.\S+\.\S+/) !== null) { 51 | // verify JWT and attach to req 52 | req.jwt = verify(headerParts[1]); 53 | next(); 54 | 55 | } else { 56 | // call callback 57 | callback(); 58 | } 59 | } catch(e) { 60 | next(e) 61 | } 62 | } 63 | 64 | 65 | /** 66 | * Custom JWT authentication middleware, to replace passport altogether 67 | * 68 | * checks if user is authenticated, otherwise returns a 401 error 69 | * */ 70 | module.exports.isAuth = async (req, res, next) => { 71 | // callback is to throw a 401 error, not authorized 72 | const callback = () => { 73 | res.status(401).json({ success: false, msg: 'Not authorized.' }); 74 | }; 75 | 76 | // call Auth helper middleware 77 | jwtAuth(req, res, next, callback); 78 | } 79 | 80 | 81 | 82 | /** 83 | * Custom JWT authentication middleware, to replace passport altogether 84 | * 85 | * checks if user is authenticated and attaches user to request object 86 | * otherwise, still allows access, just user not identified 87 | * */ 88 | module.exports.demiAuth = async (req, res, next) => { 89 | // by default, user not identified, but still allowed access. 90 | const callback = () => { 91 | req.jwt = null; 92 | next(); 93 | } 94 | 95 | // call Auth helper middleware 96 | jwtAuth(req, res, next, callback); 97 | } 98 | 99 | 100 | /** 101 | * Custom JWT authentication middleware for checkout routes 102 | * 103 | * checks if user is authenticated and attaches user to request object 104 | * otherwise, returns user to beginning of checkout route, which will lead to login/register route 105 | * */ 106 | module.exports.checkoutAuth = async (req, res, next) => { 107 | // by default, redirect to cart 108 | const callback = () => { 109 | res.redirect('/cart'); 110 | } 111 | 112 | // call Auth helper middleware 113 | jwtAuth(req, res, next, callback); 114 | } -------------------------------------------------------------------------------- /routes/auth/login.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { login } = require('../../services/authService'); 3 | const { JWTcookieOptions } = require('../../lib/customAuth/attachJWT'); 4 | require('dotenv').config(); 5 | 6 | module.exports = (app) => { 7 | 8 | app.use('/login', router); 9 | 10 | /** 11 | * @swagger 12 | * /login: 13 | * get: 14 | * tags: 15 | * - Auth 16 | * summary: Returns login page 17 | * produces: 18 | * - application/json 19 | * responses: 20 | * 200: 21 | * description: returns csrfToken 22 | */ 23 | router.get('/', (req, res, next) => { 24 | res.status(200).json({csrfToken: req.csrfToken()}); 25 | }); 26 | 27 | /** 28 | * @swagger 29 | * /login: 30 | * post: 31 | * tags: 32 | * - Auth 33 | * summary: Returns user account info and bearer token 34 | * requestBody: 35 | * description: body with necessary parameters 36 | * required: true 37 | * content: 38 | * application/json: 39 | * schema: 40 | * type: object 41 | * properties: 42 | * email: 43 | * $ref: '#/components/schemas/email' 44 | * password: 45 | * $ref: '#/components/schemas/password' 46 | * required: 47 | * - email 48 | * - password 49 | * parameters: 50 | * - name: cart_id 51 | * description: ID associated with Cart 52 | * in: cookie 53 | * required: false 54 | * schema: 55 | * $ref: '#/components/schemas/id' 56 | * responses: 57 | * 200: 58 | * description: Object with a User object and a Bearer token object. 59 | * content: 60 | * application/json: 61 | * schema: 62 | * type: object 63 | * properties: 64 | * user: 65 | * $ref: '#/components/schemas/User' 66 | * token: 67 | * type: string 68 | * expires: 69 | * type: number 70 | * cart: 71 | * $ref: '#/components/schemas/Cart' 72 | * cartItems: 73 | * type: array 74 | * items: 75 | * $ref: '#/components/schemas/CartItem' 76 | * headers: 77 | * Set-Cookie: 78 | * schema: 79 | * type: string 80 | * example: access_token=eyJhbGc...; Path=/; HttpOnly; Secure 81 | * 400: 82 | * description: Email or password missing. 83 | * 401: 84 | * description: Incorrect email or password. 85 | */ 86 | router.post('/', async (req, res, next) => { 87 | try { 88 | // grab cart_id from express session, if it exists 89 | const cart_id = req.session.cart_id ? req.session.cart_id : null; 90 | 91 | // await response 92 | const response = await login({ ...req.body, cart_id: cart_id }); 93 | 94 | // attach cart_id to session, in case cart_id changed in cart consolidation 95 | if (response.cart) { 96 | req.session.cart_id = response.cart.id; 97 | } 98 | 99 | // put jwt in a secure cookie and send to client 100 | res.cookie("access_token", response.token, JWTcookieOptions).status(200).json(response); 101 | } catch(err) { 102 | next(err); 103 | } 104 | }); 105 | }; -------------------------------------------------------------------------------- /routes/auth/register.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { register } = require('../../services/authService'); 3 | const { JWTcookieOptions } = require('../../lib/customAuth/attachJWT'); 4 | require('dotenv').config(); 5 | 6 | module.exports = (app) => { 7 | 8 | app.use('/register', router); 9 | 10 | /** 11 | * @swagger 12 | * /register: 13 | * get: 14 | * tags: 15 | * - Auth 16 | * summary: Returns registration page 17 | * produces: 18 | * - application/json 19 | * responses: 20 | * 200: 21 | * description: returns csrfToken 22 | */ 23 | router.get('/', (req, res, next) => { 24 | res.status(200).json({csrfToken: req.csrfToken()}); 25 | }); 26 | 27 | /** 28 | * @swagger 29 | * /register: 30 | * post: 31 | * tags: 32 | * - Auth 33 | * summary: Returns user account info and bearer token 34 | * requestBody: 35 | * description: body with necessary parameters 36 | * required: true 37 | * content: 38 | * application/json: 39 | * schema: 40 | * type: object 41 | * properties: 42 | * email: 43 | * $ref: '#/components/schemas/email' 44 | * password: 45 | * $ref: '#/components/schemas/password' 46 | * required: 47 | * - email 48 | * - password 49 | * parameters: 50 | * - name: cart_id 51 | * description: ID associated with Cart 52 | * in: cookie 53 | * required: false 54 | * schema: 55 | * $ref: '#/components/schemas/id' 56 | * responses: 57 | * 200: 58 | * description: Object with a User object and a Bearer token object. 59 | * content: 60 | * application/json: 61 | * schema: 62 | * type: object 63 | * properties: 64 | * user: 65 | * $ref: '#/components/schemas/User' 66 | * token: 67 | * type: string 68 | * expires: 69 | * type: number 70 | * cart: 71 | * $ref: '#/components/schemas/Cart' 72 | * cartItems: 73 | * type: array 74 | * items: 75 | * $ref: '#/components/schemas/CartItem' 76 | * headers: 77 | * Set-Cookie: 78 | * schema: 79 | * type: string 80 | * example: access_token=eyJhbGc...; Path=/; HttpOnly; Secure 81 | * 400: 82 | * $ref: '#/components/responses/InputsError' 83 | * 409: 84 | * description: Email already in use. 85 | */ 86 | router.post('/', async (req, res, next) => { 87 | try { 88 | // grab cart_id from express session, if it exists 89 | const cart_id = req.session.cart_id ? req.session.cart_id : null; 90 | 91 | // await response 92 | const response = await register({ ...req.body, cart_id: cart_id }); 93 | 94 | // attach cart_id to session, in case cart_id changed 95 | // attach cart_id to session, in case cart_id changed 96 | if (response.cart) { 97 | req.session.cart_id = response.cart.id; 98 | } 99 | 100 | // put jwt in a secure cookie and send to client 101 | res.cookie("access_token", response.token, JWTcookieOptions).status(201).json(response); 102 | } catch(err) { 103 | next(err); 104 | } 105 | }); 106 | }; -------------------------------------------------------------------------------- /tests/testData/initTestData.js: -------------------------------------------------------------------------------- 1 | const db = require('../../db'); 2 | const { genPassword } = require('../../lib/customAuth/passwordUtils'); 3 | const { users, 4 | products, 5 | carts, 6 | cart_items, 7 | addresses, 8 | cards, 9 | orders, 10 | order_items } = require('./index'); 11 | 12 | /** 13 | * Function to add test data to database 14 | * 15 | */ 16 | const initTestData = async () => { 17 | /** 18 | * Add user data to table 19 | * Need to use JS to add test user data to DB 20 | * because of hashing and salting PW 21 | * Hash and salt will be unique based on keypair generated on install 22 | */ 23 | 24 | console.log('\nAdding test data to database table. Please wait...') 25 | 26 | for (const u in users) { 27 | try { 28 | // generate salt and hash of PW 29 | const hashedPW = await genPassword(users[u].password); 30 | 31 | // pg statement 32 | const statement = `INSERT INTO users (id, email, first_name, last_name, pw_hash, pw_salt) 33 | VALUES ($1, $2, $3, $4, $5, $6)`; 34 | 35 | // pg values 36 | const values = [ users[u].id, 37 | users[u].email, 38 | users[u].first_name, 39 | users[u].last_name, 40 | hashedPW.hash, 41 | hashedPW.salt ]; 42 | 43 | // make query 44 | await db.query(statement, values); 45 | } catch(e) { 46 | break; 47 | } 48 | } 49 | 50 | // ********************************************************************** 51 | /** 52 | * Add user data to table 53 | * Use nested for loops to add remaining data 54 | */ 55 | const testData = { 56 | products, 57 | carts, 58 | cart_items, 59 | addresses, 60 | cards, 61 | orders, 62 | order_items 63 | } 64 | 65 | for (const table in testData) { 66 | 67 | for (const row in testData[table]) { 68 | // open pg statement 69 | let statement = `INSERT INTO ${table} (`; 70 | // open pg values portion of statement 71 | let valuesStatement = `VALUES (`; 72 | // instantiate counter 73 | let i = 1; 74 | // instantiate array of values 75 | let values = []; 76 | 77 | // iterate through columns and add to statement and values 78 | for (const col in testData[table][row]) { 79 | statement+=`${col}, `; 80 | valuesStatement+=`$${i}, `; 81 | i++; 82 | values.push(testData[table][row][col]); 83 | } 84 | 85 | // remove last comma and space from statement 86 | statement = statement.substring(0, statement.length - 2); 87 | valuesStatement = valuesStatement.substring(0, valuesStatement.length - 2); 88 | 89 | // close pg statement 90 | statement+=`) `; 91 | valuesStatement+=`)`; 92 | 93 | // concatenate statement and values portion of statement 94 | statement+=valuesStatement; 95 | 96 | // make query 97 | try { 98 | await db.query(statement, values); 99 | } catch(e) { 100 | break; 101 | } 102 | } 103 | } 104 | 105 | console.log(`Finishing up...`); 106 | 107 | /** 108 | * Lastly, in order to run some tests, 109 | * need to update primary address and primary payment of user7 110 | */ 111 | 112 | // pg statement 113 | const statement = `UPDATE users 114 | SET primary_address_id = $1, primary_payment_id = $2 115 | WHERE id=${users.user7.id};`; 116 | 117 | // pg values 118 | const values = [ users.user7.primary_address_id, users.user7.primary_payment_id ]; 119 | 120 | try { 121 | // make query 122 | await db.query(statement, values); 123 | } catch(e) {} 124 | 125 | console.log(`Done!`); 126 | 127 | } 128 | 129 | 130 | // run function 131 | initTestData(); 132 | -------------------------------------------------------------------------------- /routes/checkout/shipping.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { postShipping } = require('../../services/checkoutService'); 3 | const { getAllAddresses } = require('../../services/addressService'); 4 | const { checkoutAuth } = require('../../lib/customAuth/jwtAuth'); 5 | 6 | module.exports = (app) => { 7 | 8 | app.use('/shipping', router); 9 | 10 | /** 11 | * @swagger 12 | * /checkout/shipping: 13 | * get: 14 | * tags: 15 | * - Checkout 16 | * summary: Returns info for user to select shipping address 17 | * security: 18 | * - bearerJWT: [] 19 | * - cookieJWT: [] 20 | * responses: 21 | * 200: 22 | * description: Info about user's saved addresses 23 | * content: 24 | * application/json: 25 | * schema: 26 | * type: object 27 | * properties: 28 | * addresses: 29 | * type: array 30 | * items: 31 | * $ref: '#/components/schemas/Address' 32 | * 302: 33 | * description: | 34 | * Redirects to /cart if user is not authorized 35 | */ 36 | router.get('/', checkoutAuth, async (req, res, next) => { 37 | try { 38 | // grab user_id 39 | const user_id = req.jwt.sub; 40 | 41 | // get addresses 42 | const response = await getAllAddresses(user_id); 43 | 44 | res.status(200).json(response); 45 | } catch(err) { 46 | next(err); 47 | } 48 | }); 49 | 50 | /* 51 | 52 | * / 53 | 54 | /** 55 | * @swagger 56 | * /checkout/shipping: 57 | * post: 58 | * tags: 59 | * - Checkout 60 | * summary: User selects existing address or creates new address for shipping 61 | * security: 62 | * - bearerJWT: [] 63 | * - cookieJWT: [] 64 | * requestBody: 65 | * description: body with necessary parameters 66 | * required: true 67 | * content: 68 | * application/json: 69 | * schema: 70 | * oneOf: 71 | * - type: object 72 | * properties: 73 | * address_id: 74 | * $ref: '#/components/schemas/id' 75 | * - type: object 76 | * properties: 77 | * address1: 78 | * $ref: '#/components/schemas/address1' 79 | * address2: 80 | * $ref: '#/components/schemas/address2' 81 | * city: 82 | * $ref: '#/components/schemas/city' 83 | * state: 84 | * $ref: '#/components/schemas/state' 85 | * zip: 86 | * $ref: '#/components/schemas/zip' 87 | * country: 88 | * $ref: '#/components/schemas/country' 89 | * first_name: 90 | * $ref: '#/components/schemas/first_name' 91 | * last_name: 92 | * $ref: '#/components/schemas/last_name' 93 | * is_primary_address: 94 | * $ref: '#/components/schemas/is_primary_address' 95 | * responses: 96 | * 302: 97 | * description: | 98 | * Redirects to checkout/payment if shipping info input. 99 | * Redirects to checkout/shipping if inputs invalid. 100 | * Redirects to /cart if user not authenticated. 101 | */ 102 | router.post('/', checkoutAuth, async (req, res, next) => { 103 | try { 104 | // grab user_id from request 105 | const user_id = req.jwt.sub; 106 | 107 | // await response 108 | const response = await postShipping({ ...req.body, user_id: user_id }); 109 | 110 | // attach shipping address to session 111 | req.session.shipping_address_id = response.shipping.id; 112 | 113 | // redirect to get payment info 114 | res.redirect('/checkout/payment'); 115 | } catch(err) { 116 | if (err.status === 400) { 117 | res.redirect('/checkout/shipping'); 118 | } 119 | next(err); 120 | } 121 | }); 122 | 123 | } -------------------------------------------------------------------------------- /db/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "card_types" AS ENUM ( 2 | 'credit', 3 | 'debit' 4 | ); 5 | 6 | CREATE TYPE "order_status" AS ENUM ( 7 | 'pending', 8 | 'shipped', 9 | 'delivered', 10 | 'canceled' 11 | ); 12 | 13 | CREATE TYPE "categories" AS ENUM ( 14 | 'tops', 15 | 'bottoms' 16 | ); 17 | 18 | CREATE TABLE "users" ( 19 | "id" SERIAL PRIMARY KEY, 20 | "pw_hash" varchar(128) NOT NULL, 21 | "pw_salt" varchar(64) NOT NULL, 22 | "email" varchar(50) UNIQUE NOT NULL, 23 | "first_name" varchar(35), 24 | "last_name" varchar(35), 25 | "primary_address_id" int, 26 | "primary_payment_id" int, 27 | "created" timestamp NOT NULL DEFAULT (now()), 28 | "modified" timestamp NOT NULL DEFAULT (now()) 29 | ); 30 | 31 | CREATE TABLE "addresses" ( 32 | "id" SERIAL PRIMARY KEY, 33 | "user_id" int NOT NULL, 34 | "first_name" varchar(35), 35 | "last_name" varchar(35), 36 | "address1" varchar(30) NOT NULL, 37 | "address2" varchar(30), 38 | "city" varchar(30) NOT NULL, 39 | "state" varchar(2) NOT NULL, 40 | "zip" varchar(10) NOT NULL, 41 | "country" varchar(30) NOT NULL, 42 | "created" timestamp NOT NULL DEFAULT (now()), 43 | "modified" timestamp NOT NULL DEFAULT (now()) 44 | ); 45 | 46 | CREATE TABLE "cards" ( 47 | "id" SERIAL PRIMARY KEY, 48 | "user_id" int NOT NULL, 49 | "card_type" card_types NOT NULL DEFAULT ('credit'), 50 | "provider" varchar(20) NOT NULL, 51 | "card_no" varchar(16) NOT NULL, 52 | "cvv" varchar(3) NOT NULL, 53 | "exp_month" int NOT NULL, 54 | "exp_year" int NOT NULL, 55 | "billing_address_id" int NOT NULL, 56 | "created" timestamp NOT NULL DEFAULT (now()), 57 | "modified" timestamp NOT NULL DEFAULT (now()) 58 | ); 59 | 60 | CREATE TABLE "orders" ( 61 | "id" SERIAL PRIMARY KEY, 62 | "user_id" int NOT NULL, 63 | "status" order_status NOT NULL DEFAULT ('pending'), 64 | "shipping_address_id" int NOT NULL, 65 | "billing_address_id" int NOT NULL, 66 | "payment_id" int NOT NULL, 67 | "stripe_charge_id" varchar(32) NOT NULL, 68 | "amount_charged" numeric NOT NULL, 69 | "created" timestamp NOT NULL DEFAULT (now()), 70 | "modified" timestamp NOT NULL DEFAULT (now()) 71 | ); 72 | 73 | CREATE TABLE "order_items" ( 74 | "order_id" int NOT NULL, 75 | "product_id" int NOT NULL, 76 | "quantity" int NOT NULL DEFAULT 1, 77 | "created" timestamp NOT NULL DEFAULT (now()), 78 | "modified" timestamp NOT NULL DEFAULT (now()), 79 | PRIMARY KEY ("order_id", "product_id") 80 | ); 81 | 82 | CREATE TABLE "products" ( 83 | "id" SERIAL PRIMARY KEY, 84 | "name" varchar NOT NULL, 85 | "price" numeric NOT NULL, 86 | "description" text NOT NULL, 87 | "category" categories NOT NULL, 88 | "quantity" int NOT NULL DEFAULT 0, 89 | "created" timestamp NOT NULL DEFAULT (now()), 90 | "modified" timestamp NOT NULL DEFAULT (now()) 91 | ); 92 | 93 | CREATE TABLE "carts" ( 94 | "id" SERIAL PRIMARY KEY, 95 | "user_id" int UNIQUE, 96 | "created" timestamp NOT NULL DEFAULT (now()), 97 | "modified" timestamp NOT NULL DEFAULT (now()) 98 | ); 99 | 100 | CREATE TABLE "cart_items" ( 101 | "cart_id" int NOT NULL, 102 | "product_id" int NOT NULL, 103 | "quantity" int NOT NULL DEFAULT 1, 104 | "created" timestamp NOT NULL DEFAULT (now()), 105 | "modified" timestamp NOT NULL DEFAULT (now()), 106 | PRIMARY KEY("cart_id", "product_id") 107 | ); 108 | 109 | CREATE TABLE "session" ( 110 | "sid" varchar PRIMARY KEY, 111 | "sess" json NOT NULL, 112 | "expire" timestamp(6) NOT NULL 113 | ); 114 | 115 | ALTER TABLE "users" ADD FOREIGN KEY ("primary_address_id") REFERENCES "addresses" ("id"); 116 | 117 | ALTER TABLE "users" ADD FOREIGN KEY ("primary_payment_id") REFERENCES "cards" ("id"); 118 | 119 | ALTER TABLE "addresses" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id"); 120 | 121 | ALTER TABLE "cards" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id"); 122 | 123 | ALTER TABLE "cards" ADD FOREIGN KEY ("billing_address_id") REFERENCES "addresses" ("id"); 124 | 125 | ALTER TABLE "orders" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id"); 126 | 127 | ALTER TABLE "orders" ADD FOREIGN KEY ("shipping_address_id") REFERENCES "addresses" ("id"); 128 | 129 | ALTER TABLE "orders" ADD FOREIGN KEY ("billing_address_id") REFERENCES "addresses" ("id"); 130 | 131 | ALTER TABLE "orders" ADD FOREIGN KEY ("payment_id") REFERENCES "cards" ("id"); 132 | 133 | ALTER TABLE "order_items" ADD FOREIGN KEY ("order_id") REFERENCES "orders" ("id"); 134 | 135 | ALTER TABLE "order_items" ADD FOREIGN KEY ("product_id") REFERENCES "products" ("id"); 136 | 137 | ALTER TABLE "carts" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id"); 138 | 139 | ALTER TABLE "cart_items" ADD FOREIGN KEY ("cart_id") REFERENCES "carts" ("id"); 140 | 141 | ALTER TABLE "cart_items" ADD FOREIGN KEY ("product_id") REFERENCES "products" ("id"); 142 | -------------------------------------------------------------------------------- /services/paymentService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { validatePayment, validatePaymentInputs } = require('../lib/validatorUtils'); 3 | const { attachIsPrimaryPayment } = require('../lib/formatUtils'); 4 | const Card = require('../models/CardModel'); 5 | const User = require('../models/UserModel'); 6 | 7 | module.exports.postPayment = async (data) => { 8 | try { 9 | // validate inputs 10 | validatePaymentInputs(data); 11 | 12 | // create payment 13 | const payment = await Card.create(data); 14 | 15 | // if is_primary_payment, update User 16 | if (data.is_primary_payment) { 17 | // primary payment stored in User to prevent conflict 18 | await User.updatePrimaryPaymentId({ id: data.user_id, primary_payment_id: payment.id }); 19 | } 20 | 21 | // attach is_primary_payment 22 | payment.is_primary_payment = data.is_primary_payment ? true : false; 23 | 24 | return { payment }; 25 | 26 | } catch(err) { 27 | throw err; 28 | } 29 | } 30 | 31 | module.exports.getPayment = async (data) => { 32 | try { 33 | const payment = await validatePayment(data); 34 | 35 | // primary payment stored in User to prevent conflict 36 | const { primary_payment_id } = await User.findById(data.user_id); 37 | 38 | // add boolean property indicating whether address is primary address 39 | attachIsPrimaryPayment(payment, primary_payment_id); 40 | 41 | return { payment }; 42 | 43 | } catch(err) { 44 | throw(err) 45 | } 46 | } 47 | 48 | module.exports.putPayment = async (data) => { 49 | try { 50 | const payment = await validatePayment(data); 51 | 52 | // modify payment with properties in data 53 | for (const property in data) { 54 | if (property === "card_type" || 55 | property === "provider" || 56 | property === "billing_address_id" || 57 | property === "card_no" || 58 | property === "cvv" || 59 | property === "exp_month" || 60 | property === "exp_year" 61 | ) { 62 | payment[property] = data[property]; 63 | } 64 | } 65 | 66 | // validate each property before updating in db 67 | validatePaymentInputs(payment) 68 | 69 | // update payment 70 | const updatedPayment = await Card.update(payment); 71 | 72 | // attach boolean property indicating whether payment is primary payment method 73 | if (data.is_primary_payment) { 74 | // update User if true 75 | await User.updatePrimaryPaymentId({ id: data.user_id, primary_payment_id: updatedPayment.id }); 76 | updatedPayment.is_primary_payment = true; 77 | 78 | } else { 79 | updatedPayment.is_primary_payment = false; 80 | } 81 | 82 | return { payment: updatedPayment }; 83 | 84 | } catch(err) { 85 | throw err; 86 | } 87 | } 88 | 89 | module.exports.deletePayment = async (data) => { 90 | try { 91 | const payment = await validatePayment(data); 92 | 93 | // grab user assocaited with payment 94 | const { primary_payment_id } = await User.findById(data.user_id); 95 | 96 | // attach info if payment is primary payment method 97 | attachIsPrimaryPayment(payment, primary_payment_id); 98 | 99 | // check if payment is primary payment method of user 100 | if (payment.is_primary_payment) { 101 | // if so, update primary_payment_id to be null 102 | await User.updatePrimaryPaymentId({ id: data.user_id, primary_payment_id: null }); 103 | } 104 | 105 | // delete payment method 106 | const deletedPayment = await Card.delete(data.payment_id); 107 | 108 | // add boolean property indicating whether payment is primary payment 109 | attachIsPrimaryPayment(deletedPayment, primary_payment_id); 110 | 111 | return { payment: deletedPayment }; 112 | 113 | } catch(err) { 114 | throw(err) 115 | } 116 | } 117 | 118 | module.exports.getAllPayments = async (user_id) => { 119 | try { 120 | // find payment methods assocaited with user_id 121 | const payments = await Card.findByUserId(user_id); 122 | 123 | // primary payment stored in User to prevent conflict 124 | const { primary_payment_id } = await User.findById(user_id); 125 | 126 | // add boolean property indicating whether payment is primary payment 127 | payments.forEach(payment => { 128 | attachIsPrimaryPayment(payment, primary_payment_id); 129 | }); 130 | 131 | return { payments }; 132 | 133 | } catch(err) { 134 | throw err; 135 | } 136 | } -------------------------------------------------------------------------------- /models/ProductModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class Product { 4 | 5 | /** 6 | * Returns product associated with id in database, if exists 7 | * 8 | * @param {integer} id the id to find product based on 9 | * @return {Object|null} the product 10 | */ 11 | async findById(id) { 12 | try { 13 | // pg statement 14 | const statement = `SELECT *, 15 | quantity>0 AS "in_stock" 16 | FROM products 17 | WHERE id = $1`; 18 | 19 | // make query 20 | const result = await db.query(statement, [id]); 21 | 22 | // check for valid results 23 | if (result.rows.length > 0) { 24 | return result.rows[0]; 25 | } else { 26 | return null; 27 | } 28 | } catch(err) { 29 | throw new Error(err); 30 | } 31 | } 32 | 33 | /** 34 | * Returns products in the specified category, if exists 35 | * 36 | * @param {string} category the catgory to find products based on 37 | * @return {Array|null} the product(s) in the category 38 | */ 39 | async findByCategory(category) { 40 | try { 41 | // pg statement 42 | const statement = `SELECT *, 43 | quantity>0 AS "in_stock" 44 | FROM products 45 | WHERE category = $1`; 46 | 47 | // make query 48 | const result = await db.query(statement, [category]); 49 | 50 | // check for valid results 51 | if (result.rows.length > 0) { 52 | return result.rows; 53 | } else { 54 | return null; 55 | } 56 | } catch(err) { 57 | throw new Error(err); 58 | } 59 | } 60 | 61 | /** 62 | * Returns products in the specified query, if exists 63 | * 64 | * @param {String} query the query to find products based on 65 | * @return {Array|null} the product(s) that fit the query 66 | */ 67 | async findByQuery(query) { 68 | try { 69 | // pg statement 70 | const statement = `SELECT * , 71 | quantity>0 AS "in_stock" 72 | FROM products 73 | WHERE LOWER(description) 74 | LIKE LOWER('%' || $1 || '%')`; 75 | 76 | // make query 77 | const result = await db.query(statement, [query]); 78 | 79 | // check for valid results 80 | if (result.rows.length > 0) { 81 | return result.rows; 82 | } else { 83 | return null; 84 | } 85 | } catch(err) { 86 | throw new Error(err); 87 | } 88 | } 89 | 90 | /** 91 | * Returns all products in the database 92 | * 93 | * @return {Array|null} the product(s), if there are any on the database 94 | */ 95 | async getAll() { 96 | try { 97 | // pg statement 98 | const statement = `SELECT *, 99 | quantity>0 AS "in_stock" 100 | FROM products`; 101 | 102 | // make query 103 | const result = await db.query(statement); 104 | 105 | // check for valid results 106 | if (result.rows.length > 0) { 107 | return result.rows; 108 | } else { 109 | return null; 110 | } 111 | } catch(err) { 112 | throw new Error(err); 113 | } 114 | } 115 | 116 | /** 117 | * Updates quantity of product associated with id in database, if exists 118 | * 119 | * @param {integer} id the id to find product based on 120 | * @param {integer} amount the amount added or removed from quantity (positive or negative value) 121 | * @return {Object|null} the product 122 | */ 123 | async updateQuantity(id, amount) { 124 | try { 125 | // pg statement 126 | const statement = `UPDATE products 127 | SET quantity=quantity + $2, modified=now() 128 | WHERE id=$1 129 | RETURNING *, quantity>0 AS "in_stock"`; 130 | 131 | // make query 132 | const result = await db.query(statement, [id, amount]); 133 | 134 | // check for valid results 135 | if (result.rows.length > 0) { 136 | return result.rows[0]; 137 | } else { 138 | return null; 139 | } 140 | } catch(err) { 141 | throw new Error(err); 142 | } 143 | } 144 | } 145 | 146 | module.exports = new Product(); -------------------------------------------------------------------------------- /services/addressService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const { validateAddressInputs, validateAddress } = require('../lib/validatorUtils'); 3 | const { attachIsPrimaryAddress } = require('../lib/formatUtils'); 4 | const Address = require('../models/AddressModel'); 5 | const User = require('../models/UserModel'); 6 | 7 | module.exports.postAddress = async (data) => { 8 | try { 9 | // validate inputs 10 | validateAddressInputs(data); 11 | 12 | // create address 13 | const address = await Address.create(data); 14 | 15 | // if is_primary_address, update User 16 | if (data.is_primary_address) { 17 | // primary payment stored in User to avoid conflict 18 | await User.updatePrimaryAddressId({ id: data.user_id, primary_address_id: address.id }); 19 | } 20 | 21 | // attach is_primary_address 22 | address.is_primary_address = data.is_primary_address ? true : false; 23 | 24 | return { address }; 25 | 26 | } catch(err) { 27 | throw err; 28 | } 29 | } 30 | 31 | module.exports.getAddress = async (data) => { 32 | try { 33 | // validate inputs and grab address 34 | const address = await validateAddress(data); 35 | 36 | // primary address stored in User to prevent conflict 37 | const { primary_address_id } = await User.findById(data.user_id); 38 | 39 | // add boolean property indicating whether address is primary address 40 | attachIsPrimaryAddress(address, primary_address_id); 41 | 42 | return { address }; 43 | 44 | } catch(err) { 45 | throw(err) 46 | } 47 | } 48 | 49 | module.exports.putAddress = async (data) => { 50 | try { 51 | // validate inputs and grab address 52 | const address = await validateAddress(data); 53 | 54 | // modify address with properties in data 55 | for (const property in data) { 56 | if (property === "address1" || 57 | property === "address2" || 58 | property === "city" || 59 | property === "state" || 60 | property === "zip" || 61 | property === "country" || 62 | property === "first_name" || 63 | property === "last_name" 64 | ) { 65 | address[property] = data[property]; 66 | } 67 | } 68 | 69 | // validate each property before updating db 70 | validateAddressInputs(address); 71 | 72 | // update address 73 | const updatedAddress = await Address.update(address); 74 | 75 | // attach boolean property indicating whether address is primary address 76 | if (data.is_primary_address) { 77 | // update User if true 78 | await User.updatePrimaryAddressId({ id: data.user_id, primary_address_id: updatedAddress.id }); 79 | updatedAddress.is_primary_address = true; 80 | 81 | } else { 82 | updatedAddress.is_primary_address = false; 83 | } 84 | 85 | return { address: updatedAddress }; 86 | 87 | } catch(err) { 88 | throw err; 89 | } 90 | } 91 | 92 | module.exports.deleteAddress = async (data) => { 93 | try { 94 | // validate inputs and grab address 95 | const address = await validateAddress(data); 96 | 97 | // grab user assocaited with address 98 | const { primary_address_id } = await User.findById(data.user_id); 99 | 100 | // attach info if address is primary address 101 | attachIsPrimaryAddress(address, primary_address_id); 102 | 103 | // check if address is primary address of user 104 | if (address.is_primary_address) { 105 | // if so, update primary_address_id to be null 106 | await User.updatePrimaryAddressId({ id: data.user_id, primary_address_id: null }); 107 | } 108 | 109 | // delete address 110 | const deletedAddress = await Address.delete(data.address_id); 111 | 112 | // add boolean property indicating whether address is primary address 113 | attachIsPrimaryAddress(deletedAddress, primary_address_id); 114 | 115 | return { address: deletedAddress }; 116 | 117 | } catch(err) { 118 | throw(err) 119 | } 120 | } 121 | 122 | module.exports.getAllAddresses = async (user_id) => { 123 | try { 124 | // find addresses assocaited with user_id 125 | const addresses = await Address.findByUserId(user_id); 126 | 127 | // primary address stored in User to prevent conflict 128 | const { primary_address_id } = await User.findById(user_id); 129 | 130 | // add boolean property indicating whether address is primary address 131 | addresses.forEach(address => { 132 | attachIsPrimaryAddress(address, primary_address_id); 133 | }); 134 | 135 | return { addresses }; 136 | 137 | } catch(err) { 138 | throw err; 139 | } 140 | } -------------------------------------------------------------------------------- /models/OrderItemModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class OrderItem { 4 | 5 | /** 6 | * Adds new order item to the database 7 | * 8 | * @param {Object} data Contains data about new order item 9 | * @return {Oject|null} The new order item 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `WITH new_order_item AS ( 15 | INSERT INTO order_items (order_id, product_id, quantity) 16 | VALUES ($1, $2, $3) 17 | RETURNING * 18 | ) 19 | SELECT 20 | new_order_item.*, 21 | products.name, 22 | products.price * new_order_item.quantity AS "total_price", 23 | products.description, 24 | products.quantity > 0 AS "in_stock" 25 | FROM new_order_item 26 | JOIN products 27 | ON new_order_item.product_id = products.id`; 28 | 29 | // pg values 30 | const values = [data.order_id, data.product_id, data.quantity] 31 | 32 | // make query 33 | const result = await db.query(statement, values); 34 | 35 | // check for valid results 36 | if (result.rows.length > 0) { 37 | return result.rows[0]; 38 | } else { 39 | return null; 40 | } 41 | } catch(err) { 42 | throw new Error(err); 43 | } 44 | } 45 | 46 | /** 47 | * Returns order items associated with order_id in database, if exists 48 | * 49 | * @param {number} order_id the order_id to find order items based on 50 | * @return {Array|null} the order items 51 | */ 52 | async findInOrder(order_id) { 53 | try { 54 | // pg statement 55 | const statement = `WITH temporary_order AS ( 56 | SELECT * 57 | FROM order_items 58 | WHERE order_id = $1 59 | ) 60 | SELECT 61 | temporary_order.*, 62 | products.name, 63 | products.price * temporary_order.quantity AS "total_price", 64 | products.description, 65 | products.quantity > 0 AS "in_stock" 66 | FROM temporary_order 67 | JOIN products 68 | ON temporary_order.product_id = products.id`; 69 | 70 | // pg values 71 | const values = [order_id]; 72 | 73 | // make query 74 | const result = await db.query(statement, values); 75 | 76 | // check for valid results 77 | if (result.rows.length > 0) { 78 | return result.rows; 79 | } else { 80 | return null; 81 | } 82 | } catch(err) { 83 | throw new Error(err); 84 | } 85 | } 86 | 87 | /** 88 | * Deletes order item from database , if exists 89 | * 90 | * @param {Object} data 91 | * @return {Object|null} the order item 92 | */ 93 | async delete(data) { 94 | try { 95 | // pg statement 96 | const statement = `WITH deleted_item AS ( 97 | DELETE FROM order_items 98 | WHERE order_id=$1 AND product_id=$2 99 | RETURNING * 100 | ) 101 | SELECT 102 | deleted_item.*, 103 | products.name, 104 | products.price * deleted_item.quantity AS "total_price", 105 | products.description, 106 | products.quantity > 0 AS "in_stock" 107 | FROM deleted_item 108 | JOIN products 109 | ON deleted_item.product_id = products.id`; 110 | 111 | // pg values 112 | const values = [data.order_id, data.product_id]; 113 | 114 | // make query 115 | const result = await db.query(statement, values); 116 | 117 | // check for valid results 118 | if (result.rows.length > 0) { 119 | return result.rows[0]; 120 | } else { 121 | return null; 122 | } 123 | } catch(err) { 124 | throw new Error(err); 125 | } 126 | } 127 | } 128 | 129 | module.exports = new OrderItem(); -------------------------------------------------------------------------------- /tests/testUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions for running tests 3 | */ 4 | const { faker } = require('@faker-js/faker'); 5 | const User = require('../models/UserModel'); 6 | 7 | 8 | /** 9 | * Creates a new fake user using faker.js 10 | * @return {Object} with properties: 11 | * - first_name 12 | * - last_name 13 | * - password 14 | * - email 15 | * 16 | */ 17 | const createUser = () => { 18 | const first_name = faker.name.firstName(); 19 | const last_name = faker.name.lastName(); 20 | // password with minimum eight characters, at least one uppercase letter, one lowercase letter and one number 21 | const password = faker.internet.password(20, true, /"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$"/) 22 | const email = `${first_name}_${last_name}@me.com`; 23 | 24 | return { 25 | first_name, 26 | last_name, 27 | password, 28 | email 29 | } 30 | } 31 | 32 | /** 33 | * Adds a user to the db by registering and 34 | * generates auth token in testSession cookie 35 | * 36 | * @param {Object} user has properties: 37 | * - email 38 | * - password 39 | * @param {Object} testSession the test testSession 40 | * @param {String} csrfToken csrf token to verify authenticity of request 41 | * 42 | * @return {Number} user id 43 | */ 44 | const registerUser = async (user, testSession, csrfToken) => { 45 | try { 46 | // log user in to get cookie with JWT 47 | const res = await testSession 48 | .post('/register') 49 | .send(user) 50 | .set('Accept', 'application/json') 51 | .set(`XSRF-TOKEN`, csrfToken); 52 | 53 | // return id and testSession w cookeies 54 | return res.body.user.id; 55 | } catch(e) { 56 | console.log(e); 57 | } 58 | } 59 | 60 | 61 | /** 62 | * Logs user in to generate auth token in cookies 63 | * 64 | * @param {Object} user has properties: 65 | * - email 66 | * - password 67 | * @param {Object} testSession the test testSession 68 | * @param {String} csrfToken csrf token to verify authenticity of request 69 | * 70 | * @return {Srting} JWT 71 | */ 72 | const loginUser = async (user, testSession, csrfToken) => { 73 | try { 74 | // log user in to get cookie with JWT 75 | const res = await testSession 76 | .post('/login') 77 | .send(user) 78 | .set('Accept', 'application/json') 79 | .set(`XSRF-TOKEN`, csrfToken); 80 | return res.body.token; 81 | } catch(e) { 82 | console.log(e); 83 | } 84 | } 85 | 86 | /** 87 | * Creates a new cart 88 | * 89 | * @param {Object} testSession the test testSession 90 | * @param {String} csrfToken csrf token to verify authenticity of request 91 | * 92 | * @return {Number} id of the new cart 93 | */ 94 | const createCart = async (testSession, csrfToken) => { 95 | try { 96 | const res = await testSession 97 | .post('/cart') 98 | .set('Accept', 'application/json') 99 | .set(`XSRF-TOKEN`, csrfToken); 100 | return res.body.cart.id; 101 | 102 | } catch(e) { 103 | console.log(e); 104 | } 105 | } 106 | 107 | /** 108 | * Creates a new cart item 109 | * 110 | * @param {Object} product to add as a new cart item, has properties: 111 | * - product_id: id of product 112 | * @param {Object} testSession the test testSessions 113 | * @param {String} csrfToken csrf token to verify authenticity of request 114 | */ 115 | const createCartItem = async (product, testSession, csrfToken) => { 116 | try { 117 | const res = await testSession 118 | .post(`/cart/item/${product.product_id}`) 119 | .send(product) 120 | .set('Accept', 'application/json') 121 | .set(`XSRF-TOKEN`, csrfToken); 122 | return res.body.cartItem.cart_id; 123 | } catch(e) { 124 | console.log(e); 125 | } 126 | } 127 | 128 | 129 | 130 | /** 131 | * Creates a new csrfToken 132 | * @param {Object} testSession the test testSessions 133 | * 134 | * @return {String} XSRF Token value 135 | */ 136 | const createCSRFToken = async (testSession) => { 137 | try { 138 | const res = await testSession 139 | .get(`/`) 140 | .set('Accept', 'application/json'); 141 | const XSRFToken = testSession.cookies.find((cookie) => { 142 | return cookie.name === `XSRF-TOKEN`; 143 | }); 144 | return XSRFToken.value; 145 | } catch(e) { 146 | console.log(e); 147 | } 148 | } 149 | 150 | 151 | 152 | /** 153 | * Gets an existing csrfToken 154 | * @param {Object} testSession the test testSessions 155 | * 156 | * @return {String} XSRF Token value 157 | */ 158 | const getCSRFToken = async (testSession) => { 159 | try { 160 | const XSRFToken = testSession.cookies.find((cookie) => { 161 | return cookie.name === `XSRF-TOKEN`; 162 | }); 163 | return XSRFToken.value; 164 | } catch(e) { 165 | console.log(e); 166 | } 167 | } 168 | 169 | 170 | 171 | module.exports = { 172 | createUser, 173 | registerUser, 174 | loginUser, 175 | createCart, 176 | createCartItem, 177 | createCSRFToken, 178 | getCSRFToken 179 | } -------------------------------------------------------------------------------- /routes/account/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const ordersRouter = require('./orders'); 3 | const addressRouter = require('./address'); 4 | const paymentsRouter = require('./payment'); 5 | const { getAccount, putAccount } = require('../../services/accountService'); 6 | const { isAuth } = require('../../lib/customAuth/jwtAuth'); 7 | 8 | module.exports = (app) => { 9 | 10 | app.use('/account', router); 11 | 12 | // authenticate user to access route 13 | router.use(isAuth); 14 | 15 | /** 16 | * @swagger 17 | * components: 18 | * schemas: 19 | * email: 20 | * type: string 21 | * format: email 22 | * example: 'user@example.com' 23 | * password: 24 | * type: string 25 | * format: password 26 | * minLength: 8 27 | * pattern: ^(?=.*[A-Z])(?=.*[!@#$&*])(?=.*[0-9])(?=.*[a-z]).{8,}$ 28 | * example: 'str0ngPassw!rd' 29 | * nullable_id: 30 | * type: integer 31 | * nullable: true 32 | * minimum: 1 33 | * example: 125 34 | * User: 35 | * type: object 36 | * properties: 37 | * id: 38 | * $ref: '#/components/schemas/id' 39 | * email: 40 | * $ref: '#/components/schemas/email' 41 | * first_name: 42 | * $ref: '#/components/schemas/first_name' 43 | * last_name: 44 | * $ref: '#/components/schemas/last_name' 45 | * primary_address_id: 46 | * $ref: '#/components/schemas/nullable_id' 47 | * primary_payment_id: 48 | * $ref: '#/components/schemas/nullable_id' 49 | * created: 50 | * $ref: '#/components/schemas/date_time' 51 | * modified: 52 | * $ref: '#/components/schemas/date_time' 53 | * 54 | */ 55 | 56 | 57 | /** 58 | * @swagger 59 | * /account: 60 | * get: 61 | * tags: 62 | * - Account 63 | * summary: Returns user account info 64 | * security: 65 | * - bearerJWT: [] 66 | * - cookieJWT: [] 67 | * responses: 68 | * 200: 69 | * description: A User object 70 | * content: 71 | * application/json: 72 | * schema: 73 | * type: object 74 | * properties: 75 | * user: 76 | * $ref: '#/components/schemas/User' 77 | * 401: 78 | * $ref: '#/components/responses/UnauthorizedError' 79 | * 404: 80 | * description: A User with the id was not found. 81 | */ 82 | router.get('/', async (req, res ,next) => { 83 | try { 84 | // grab user_id from jwt 85 | const user_id = req.jwt.sub; 86 | 87 | // await response 88 | const response = await getAccount(user_id); 89 | 90 | // send response to client 91 | res.status(200).json(response); 92 | } catch(err) { 93 | next(err); 94 | } 95 | }); 96 | 97 | /** 98 | * @swagger 99 | * /account: 100 | * put: 101 | * tags: 102 | * - Account 103 | * summary: Returns user account info 104 | * security: 105 | * - bearerJWT: [] 106 | * - cookieJWT: [] 107 | * requestBody: 108 | * description: body with necessary parameters 109 | * required: false 110 | * content: 111 | * application/json: 112 | * schema: 113 | * type: object 114 | * properties: 115 | * email: 116 | * $ref: '#/components/schemas/email' 117 | * password: 118 | * $ref: '#/components/schemas/password' 119 | * first_name: 120 | * $ref: '#/components/schemas/first_name' 121 | * last_name: 122 | * $ref: '#/components/schemas/last_name' 123 | * responses: 124 | * 200: 125 | * description: A User object 126 | * content: 127 | * application/json: 128 | * schema: 129 | * type: object 130 | * properties: 131 | * user: 132 | * $ref: '#/components/schemas/User' 133 | * 401: 134 | * $ref: '#/components/responses/UnauthorizedError' 135 | * 404: 136 | * description: A User with the ID was not found. 137 | */ 138 | router.put('/', async (req, res ,next) => { 139 | try { 140 | // grab user_id from jwt 141 | const user_id = req.jwt.sub; 142 | 143 | // await response 144 | const response = await putAccount({ ...req.body, user_id: user_id }); 145 | 146 | // send response to client 147 | res.status(200).json(response); 148 | } catch(err) { 149 | next(err); 150 | } 151 | }); 152 | 153 | // extend route to user's orders 154 | ordersRouter(router); 155 | 156 | //extend route to user's addresses 157 | addressRouter(router); 158 | 159 | // extend route to user's payment methods 160 | paymentsRouter(router); 161 | } -------------------------------------------------------------------------------- /services/orderService.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const Order = require('../models/OrderModel'); 3 | const OrderItem = require('../models/OrderItemModel'); 4 | const Cart = require('../models/CartModel'); 5 | const CartItem = require('../models/CartItemModel'); 6 | const User = require('../models/UserModel'); 7 | const { validateID, 8 | validatePrice, 9 | validateAddressInputs, 10 | validatePaymentInputs, 11 | validateCartItemInputs, 12 | validateCart } = require('../lib/validatorUtils'); 13 | require('dotenv').config(); 14 | const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); 15 | 16 | module.exports.postOrder = async (data) => { 17 | try { 18 | 19 | // validate inputs 20 | validateID(data.user_id); 21 | validateCart(data.cart); 22 | validateAddressInputs(data.billing); 23 | validateAddressInputs(data.shipping); 24 | validatePaymentInputs(data.payment); 25 | 26 | // --------------------------------------------------------- 27 | // ----- charge Card with payment_id using Stripe API ------ 28 | // --------------------------------------------------------- 29 | 30 | /* 31 | * Keep stripe section atomized to be easily repleaced with 32 | * another payment processing api 33 | */ 34 | 35 | // create cardToken to charge 36 | const cardToken = await stripe.tokens.create({ 37 | card: { 38 | name: data.billing.first_name + " " + data.billing.last_name, 39 | number: data.payment.card_no, 40 | exp_month: data.payment.exp_month, 41 | exp_year: data.payment.exp_year, 42 | cvc: data.payment.cvv, 43 | address_line1: data.billing.address1, 44 | address_line2: data.billing.address2, 45 | address_city: data.billing.city, 46 | address_state: data.billing.state, 47 | address_zip: data.billing.zip, 48 | address_country: data.billing.country, 49 | } 50 | }); 51 | 52 | // charge card with Stripe 53 | const charge = await stripe.charges.create({ 54 | amount: Math.round(data.cart.total * 100), // stripe charges in usd cents, round to avoid error 55 | currency: "usd", 56 | source: cardToken.id, 57 | }); 58 | 59 | if (!charge.status === "succeeded") { 60 | // throw error 61 | throw httpError(400, 'Error processing payment.'); 62 | } 63 | 64 | // --------------------------------------------------------- 65 | // ----------------- end charge section -------------------- 66 | // --------------------------------------------------------- 67 | 68 | // create an new order 69 | const newOrder = await Order.create({ 70 | user_id: data.user_id, 71 | shipping_address_id: data.shipping.id, 72 | billing_address_id: data.billing.id, 73 | payment_id: data.payment.id, 74 | amount_charged: data.cart.total, 75 | stripe_charge_id: charge.id 76 | }); 77 | 78 | const { cartItems } = data; 79 | 80 | // iterate through cart items to create order items 81 | var orderItems = []; 82 | for (const cartItem of cartItems) { 83 | // create new order item 84 | const newOrderItem = await OrderItem.create({ ...cartItem, order_id: newOrder.id }); 85 | 86 | // delete cart item from database 87 | const deletedCartItem = await CartItem.delete({ ...cartItem }); 88 | 89 | // add item to order items 90 | orderItems.push(newOrderItem); 91 | } 92 | 93 | // delete cart from database 94 | const deletedCart = await Cart.delete(data.cart.id); 95 | 96 | // attach num_items to newOrder 97 | newOrder.num_items = data.cart.num_items; 98 | 99 | return { 100 | order: newOrder, 101 | orderItems: orderItems 102 | } 103 | 104 | } catch(err) { 105 | throw err; 106 | } 107 | } 108 | 109 | module.exports.getAllOrders = async (user_id) => { 110 | try { 111 | // validate inputs 112 | validateID(user_id); 113 | 114 | // find orders assocaited with user_id 115 | const orders = await Order.findByUserId(user_id); 116 | 117 | return { orders }; 118 | 119 | } catch(err) { 120 | throw err; 121 | } 122 | } 123 | 124 | module.exports.getOneOrder = async (data) => { 125 | try { 126 | // validate inputs 127 | validateID(data.order_id); 128 | validateID(data.user_id); 129 | 130 | // find order 131 | const order = await Order.findById(data.order_id); 132 | 133 | // throw error if order doesn't exist 134 | if(!order) { 135 | throw httpError(404, 'Order not found'); 136 | } 137 | 138 | // throw error if user did not place order 139 | if(order.user_id !== data.user_id) { 140 | throw httpError(403, 'User did not place order'); 141 | } 142 | 143 | // find order items 144 | const orderItems = await OrderItem.findInOrder(data.order_id); 145 | 146 | return { order, orderItems }; 147 | 148 | } catch(err) { 149 | throw err; 150 | } 151 | } -------------------------------------------------------------------------------- /routes/shop/cart.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const cartItemsRouter = require('./cartItems'); 3 | const { postCart, getCart } = require('../../services/cartService'); 4 | const { demiAuth } = require('../../lib/customAuth/jwtAuth'); 5 | 6 | module.exports = (app) => { 7 | 8 | app.use('/cart', router); 9 | 10 | // demi auth will still allow a user access if they are not logged in 11 | router.use(demiAuth); 12 | 13 | /** 14 | * @swagger 15 | * components: 16 | * schemas: 17 | * product_id: 18 | * type: integer 19 | * minimum: 1 20 | * example: 3 21 | * quantity: 22 | * type: integer 23 | * example: 2 24 | * minimum: 1 25 | * maximum: 100 26 | * Cart: 27 | * type: object 28 | * properties: 29 | * id: 30 | * $ref: '#/components/schemas/id' 31 | * user_id: 32 | * $ref: '#/components/schemas/id' 33 | * total: 34 | * $ref: '#/components/schemas/price' 35 | * num_items: 36 | * $ref: '#/components/schemas/num_products' 37 | * created: 38 | * $ref: '#/components/schemas/date_time' 39 | * modified: 40 | * $ref: '#/components/schemas/date_time' 41 | * CartItem: 42 | * type: object 43 | * properties: 44 | * cart_id: 45 | * $ref: '#/components/schemas/id' 46 | * product_id: 47 | * $ref: '#/components/schemas/product_id' 48 | * quantity: 49 | * $ref: '#/components/schemas/quantity' 50 | * name: 51 | * $ref: '#/components/schemas/product_name' 52 | * total_price: 53 | * $ref: '#/components/schemas/price' 54 | * description: 55 | * $ref: '#/components/schemas/product_description' 56 | * in_stock: 57 | * $ref: '#/components/schemas/in_stock' 58 | * created: 59 | * $ref: '#/components/schemas/date_time' 60 | * modified: 61 | * $ref: '#/components/schemas/date_time' 62 | * 63 | */ 64 | 65 | /** 66 | * @swagger 67 | * /cart: 68 | * post: 69 | * tags: 70 | * - Shop 71 | * summary: Creates and returns a new empty cart 72 | * responses: 73 | * 201: 74 | * description: A Cart object. 75 | * content: 76 | * application/json: 77 | * schema: 78 | * type: object 79 | * properties: 80 | * cart: 81 | * $ref: '#/components/schemas/Cart' 82 | * cartItems: 83 | * type: array 84 | * items: 85 | * $ref: '#/components/schemas/CartItem' 86 | * headers: 87 | * Set-Cookie: 88 | * schema: 89 | * type: string 90 | * example: connect.sid=s%3ApzUS6...; Path=/; HttpOnly; Secure 91 | * 500: 92 | * description: Server error creating cart. 93 | */ 94 | router.post('/', async (req, res ,next) => { 95 | try { 96 | // grab user_id from json web token 97 | const user_id = req.jwt ? req.jwt.sub : null; 98 | 99 | // await response 100 | const response = await postCart(user_id); 101 | 102 | // attach cart_id to session 103 | req.session.cart_id = response.cart.id; 104 | 105 | // send response to client 106 | res.status(201).json(response); 107 | } catch(err) { 108 | next(err); 109 | } 110 | }); 111 | 112 | /** 113 | * @swagger 114 | * /cart: 115 | * get: 116 | * tags: 117 | * - Shop 118 | * summary: Returns cart with items 119 | * parameters: 120 | * - name: cart_id 121 | * description: ID associated with Cart 122 | * in: cookie 123 | * required: true 124 | * type: integer 125 | * responses: 126 | * 200: 127 | * description: A Cart object and an array of CartItem objects. 128 | * content: 129 | * application/json: 130 | * schema: 131 | * type: object 132 | * properties: 133 | * cart: 134 | * $ref: '#/components/schemas/Cart' 135 | * cartItems: 136 | * type: array 137 | * items: 138 | * $ref: '#/components/schemas/CartItem' 139 | * 400: 140 | * description: Missing cart_id. 141 | * 404: 142 | * description: Cart associated with cart_id doesn't exist or is empty. 143 | */ 144 | router.get('/', async (req, res ,next) => { 145 | try { 146 | // grab cart_id from express session, if it exists 147 | const cart_id = req.session.cart_id || null; 148 | 149 | // grad user_id, if it exists 150 | const user_id = req.jwt ? req.jwt.sub : null; 151 | 152 | // await response 153 | const response = await getCart(cart_id, user_id); 154 | 155 | // send response to client 156 | res.status(200).json(response); 157 | } catch(err) { 158 | next(err); 159 | } 160 | }); 161 | 162 | // extend route to cart_items 163 | cartItemsRouter(router); 164 | } -------------------------------------------------------------------------------- /routes/account/orders.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router({ mergeParams : true }); 2 | const { getAllOrders, getOneOrder } = require('../../services/orderService'); 3 | 4 | module.exports = (app) => { 5 | 6 | app.use('/orders', router); 7 | 8 | /** 9 | * @swagger 10 | * components: 11 | * schemas: 12 | * status: 13 | * type: string 14 | * enum: 15 | * - pending 16 | * - shipped 17 | * - delivered 18 | * - canceled 19 | * example: 'pending' 20 | * price: 21 | * type: number 22 | * format: money 23 | * example: 19.99 24 | * num_products: 25 | * type: integer 26 | * minimum: 1 27 | * example: 3 28 | * product_name: 29 | * type: string 30 | * pattern: ^[A-Za-z0-9 '#:_-]*$ 31 | * example: 'T-shirt' 32 | * product_description: 33 | * type: string 34 | * pattern: ^[A-Za-z0-9 '#:_-]*$ 35 | * example: 'Imported pima cotton unisex t-shirt' 36 | * in_stock: 37 | * type: boolean 38 | * example: true 39 | * Order: 40 | * type: object 41 | * properties: 42 | * id: 43 | * $ref: '#/components/schemas/id' 44 | * user_id: 45 | * $ref: '#/components/schemas/id' 46 | * shipping_address_id: 47 | * $ref: '#/components/schemas/id' 48 | * billing_address_id: 49 | * $ref: '#/components/schemas/id' 50 | * payment_id: 51 | * $ref: '#/components/schemas/id' 52 | * stripe_charge_id: 53 | * $ref: '#/components/schemas/id' 54 | * status: 55 | * $ref: '#/components/schemas/status' 56 | * amount_charged: 57 | * $ref: '#/components/schemas/price' 58 | * num_items: 59 | * $ref: '#/components/schemas/num_products' 60 | * created: 61 | * $ref: '#/components/schemas/date_time' 62 | * modified: 63 | * $ref: '#/components/schemas/date_time' 64 | * OrderItem: 65 | * type: object 66 | * properties: 67 | * order_id: 68 | * $ref: '#/components/schemas/id' 69 | * product_id: 70 | * $ref: '#/components/schemas/id' 71 | * quantity: 72 | * $ref: '#/components/schemas/num_products' 73 | * name: 74 | * $ref: '#/components/schemas/product_name' 75 | * total_price: 76 | * $ref: '#/components/schemas/price' 77 | * description: 78 | * $ref: '#/components/schemas/product_description' 79 | * in_stock: 80 | * $ref: '#/components/schemas/in_stock' 81 | * created: 82 | * $ref: '#/components/schemas/date_time' 83 | * modified: 84 | * $ref: '#/components/schemas/date_time' 85 | * 86 | */ 87 | 88 | /** 89 | * @swagger 90 | * /account/orders/all: 91 | * get: 92 | * tags: 93 | * - Account 94 | * summary: Returns all orders associated with user 95 | * security: 96 | * - bearerJWT: [] 97 | * - cookieJWT: [] 98 | * responses: 99 | * 200: 100 | * description: An array of Order objects. 101 | * content: 102 | * application/json: 103 | * schema: 104 | * type: object 105 | * properties: 106 | * orders: 107 | * type: array 108 | * items: 109 | * $ref: '#/components/schemas/Order' 110 | * 401: 111 | * $ref: '#/components/responses/UnauthorizedError' 112 | */ 113 | router.get('/all', async (req, res ,next) => { 114 | try { 115 | const user_id = req.jwt.sub; 116 | 117 | const response = await getAllOrders(user_id); 118 | 119 | res.status(200).json(response); 120 | } catch(err) { 121 | next(err); 122 | } 123 | }); 124 | 125 | /** 126 | * @swagger 127 | * /account/orders/{order_id}: 128 | * parameters: 129 | * - in: path 130 | * name: order_id 131 | * description: ID of order 132 | * required: true 133 | * type: string 134 | * get: 135 | * tags: 136 | * - Account 137 | * summary: Returns order associated with specified order_id 138 | * security: 139 | * - bearerJWT: [] 140 | * - cookieJWT: [] 141 | * responses: 142 | * 200: 143 | * description: An Order object. 144 | * content: 145 | * application/json: 146 | * schema: 147 | * type: object 148 | * properties: 149 | * order: 150 | * $ref: '#/components/schemas/Product' 151 | * 400: 152 | * $ref: '#/components/responses/InputsError' 153 | * 401: 154 | * $ref: '#/components/responses/UnauthorizedError' 155 | * 403: 156 | * description: User not associated with specified order_id. 157 | * 404: 158 | * description: No order found with specified order_id. 159 | */ 160 | router.get('/:order_id', async (req, res ,next) => { 161 | try { 162 | const data = { 163 | user_id: req.jwt.sub, 164 | order_id: req.params.order_id 165 | }; 166 | 167 | const response = await getOneOrder(data); 168 | 169 | res.status(200).json(response); 170 | } catch(err) { 171 | next(err); 172 | } 173 | }); 174 | } -------------------------------------------------------------------------------- /models/CardModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class Card { 4 | 5 | /** 6 | * Adds new card to the database 7 | * 8 | * @param {data} data The card info to enter into database 9 | * @return {Oject} The new card 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `INSERT INTO cards ( 15 | card_type, 16 | provider, 17 | card_no, 18 | cvv, 19 | exp_month, 20 | exp_year, 21 | billing_address_id, 22 | user_id) 23 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 24 | RETURNING *`; 25 | 26 | // pg values 27 | const values = [data.card_type, 28 | data.provider, 29 | data.card_no, 30 | data.cvv, 31 | data.exp_month, 32 | data.exp_year, 33 | data.billing_address_id, 34 | data.user_id]; 35 | 36 | // make query 37 | const result = await db.query(statement, values); 38 | 39 | // check for valid results 40 | if (result.rows.length > 0) { 41 | return result.rows[0]; 42 | } else { 43 | return null; 44 | } 45 | } catch(err) { 46 | throw new Error(err); 47 | } 48 | } 49 | 50 | 51 | /** 52 | * Updates a card in the database 53 | * 54 | * @param {Object} data Data about card to update 55 | * @return {Oject} The updated card 56 | */ 57 | async update(data) { 58 | try { 59 | // pg statement 60 | const statement = `UPDATE cards 61 | SET card_type=$2, 62 | provider=$3, 63 | card_no=$4, 64 | cvv=$5, 65 | exp_month=$6, 66 | exp_year=$7, 67 | billing_address_id=$8, 68 | modified=now() 69 | WHERE id = $1 70 | RETURNING *`; 71 | 72 | // pg values 73 | const values = [data.id, 74 | data.card_type, 75 | data.provider, 76 | data.card_no, 77 | data.cvv, 78 | data.exp_month, 79 | data.exp_year, 80 | data.billing_address_id]; 81 | 82 | // make query 83 | const result = await db.query(statement, values); 84 | 85 | // check for valid results 86 | if (result.rows.length > 0) { 87 | return result.rows[0]; 88 | } else { 89 | return null; 90 | } 91 | } catch(err) { 92 | throw new Error(err); 93 | } 94 | } 95 | 96 | /** 97 | * Returns card associated with id in database, if exists 98 | * 99 | * @param {number} id the id to find card based on 100 | * @return {Object|null} the card 101 | */ 102 | async findById(id) { 103 | try { 104 | // pg statement 105 | const statement = `SELECT * 106 | FROM cards 107 | WHERE id = $1`; 108 | 109 | // make query 110 | const result = await db.query(statement, [id]); 111 | 112 | // check for valid results 113 | if (result.rows.length > 0) { 114 | return result.rows[0]; 115 | } else { 116 | return null; 117 | } 118 | } catch(err) { 119 | throw new Error(err); 120 | } 121 | } 122 | 123 | /** 124 | * Returns all cards associated with user_id in database, if exists 125 | * 126 | * @param {number} user_id the user's id to find cards based on 127 | * @return {Arrray} the cards 128 | */ 129 | async findByUserId(user_id) { 130 | try { 131 | // pg statement 132 | const statement = `SELECT * 133 | FROM cards 134 | WHERE user_id = $1`; 135 | 136 | // make query 137 | const result = await db.query(statement, [user_id]); 138 | 139 | // check for valid results 140 | if (result.rows.length > 0) { 141 | return result.rows; 142 | } else { 143 | return []; 144 | } 145 | } catch(err) { 146 | throw new Error(err); 147 | } 148 | } 149 | 150 | /** 151 | * Deletes card associated with id in database, if exists 152 | * 153 | * @param {string} id the id to delete card based on 154 | * @return {Object|null} the card 155 | */ 156 | async delete(id) { 157 | try { 158 | // pg statement 159 | const statement = `DELETE FROM cards 160 | WHERE id=$1 161 | RETURNING *` 162 | 163 | // make query 164 | const result = await db.query(statement, [id]); 165 | 166 | // check for valid results 167 | if (result.rows.length > 0) { 168 | return result.rows[0]; 169 | } else { 170 | return null; 171 | } 172 | } catch(err) { 173 | throw new Error(err); 174 | } 175 | } 176 | } 177 | 178 | module.exports = new Card(); -------------------------------------------------------------------------------- /models/AddressModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class Address { 4 | 5 | /** 6 | * Adds new address to the database 7 | * 8 | * @param {data} data The address info to enter into database 9 | * @return {Oject} The new address 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `INSERT INTO addresses ( 15 | address1, 16 | address2, 17 | city, 18 | state, 19 | zip, 20 | country, 21 | first_name, 22 | last_name, 23 | user_id) 24 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 25 | RETURNING *`; 26 | 27 | // pg values 28 | const values = [data.address1, 29 | data.address2, 30 | data.city, 31 | data.state, 32 | data.zip, 33 | data.country, 34 | data.first_name, 35 | data.last_name, 36 | data.user_id]; 37 | 38 | // make query 39 | const result = await db.query(statement, values); 40 | 41 | // check for valid results 42 | if (result.rows.length > 0) { 43 | return result.rows[0]; 44 | } else { 45 | return null; 46 | } 47 | } catch(err) { 48 | throw new Error(err); 49 | } 50 | } 51 | 52 | 53 | /** 54 | * Updates an address in the database 55 | * 56 | * @param {Object} data Data about address to update 57 | * @return {Oject} The updated address 58 | */ 59 | async update(data) { 60 | try { 61 | // pg statement 62 | const statement = `UPDATE addresses 63 | SET address1=$2, 64 | address2=$3, 65 | city=$4, 66 | state=$5, 67 | zip=$6, 68 | country=$7, 69 | first_name=$8, 70 | last_name=$9, 71 | modified=now() 72 | WHERE id = $1 73 | RETURNING *`; 74 | 75 | // pg values 76 | const values = [data.id, 77 | data.address1, 78 | data.address2, 79 | data.city, 80 | data.state, 81 | data.zip, 82 | data.country, 83 | data.first_name, 84 | data.last_name]; 85 | 86 | // make query 87 | const result = await db.query(statement, values); 88 | 89 | // check for valid results 90 | if (result.rows.length > 0) { 91 | return result.rows[0]; 92 | } else { 93 | return null; 94 | } 95 | } catch(err) { 96 | throw new Error(err); 97 | } 98 | } 99 | 100 | /** 101 | * Returns address associated with id in database, if exists 102 | * 103 | * @param {number} id the id to find address based on 104 | * @return {Object|null} the address 105 | */ 106 | async findById(id) { 107 | try { 108 | // pg statement 109 | const statement = `SELECT * 110 | FROM addresses 111 | WHERE id = $1`; 112 | 113 | // make query 114 | const result = await db.query(statement, [id]); 115 | 116 | // check for valid results 117 | if (result.rows.length > 0) { 118 | return result.rows[0]; 119 | } else { 120 | return null; 121 | } 122 | } catch(err) { 123 | throw new Error(err); 124 | } 125 | } 126 | 127 | /** 128 | * Returns all addresses associated with user_id in database, if exists 129 | * 130 | * @param {number} user_id the user's id to find address based on 131 | * @return {Arrray} the addresses 132 | */ 133 | async findByUserId(user_id) { 134 | try { 135 | // pg statement 136 | const statement = `SELECT * 137 | FROM addresses 138 | WHERE user_id = $1`; 139 | 140 | // make query 141 | const result = await db.query(statement, [user_id]); 142 | 143 | // check for valid results 144 | if (result.rows.length > 0) { 145 | return result.rows; 146 | } else { 147 | return []; 148 | } 149 | } catch(err) { 150 | throw new Error(err); 151 | } 152 | } 153 | 154 | /** 155 | * Deletes address associated with id in database, if exists 156 | * 157 | * @param {string} id the id to delete address based on 158 | * @return {Object|null} the address 159 | */ 160 | async delete(id) { 161 | try { 162 | // pg statement 163 | const statement = `DELETE FROM addresses 164 | WHERE id=$1 165 | RETURNING *` 166 | 167 | // make query 168 | const result = await db.query(statement, [id]); 169 | 170 | // check for valid results 171 | if (result.rows.length > 0) { 172 | return result.rows[0]; 173 | } else { 174 | return null; 175 | } 176 | } catch(err) { 177 | throw new Error(err); 178 | } 179 | } 180 | } 181 | 182 | module.exports = new Address(); -------------------------------------------------------------------------------- /routes/shop/products.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { getAll, 3 | getById, 4 | getCategory, 5 | getSearch } = require('../../services/productService'); 6 | 7 | module.exports = (app) => { 8 | 9 | app.use('/products', router); 10 | 11 | /** 12 | * @swagger 13 | * components: 14 | * schemas: 15 | * category: 16 | * type: string 17 | * pattern: ^[A-Za-z0-9 '#:_-]*$ 18 | * example: 'tops' 19 | * query: 20 | * type: string 21 | * pattern: ^[A-Za-z0-9 '#:_-]*$ 22 | * example: 'pants' 23 | * Product: 24 | * type: object 25 | * properties: 26 | * id: 27 | * $ref: '#/components/schemas/id' 28 | * name: 29 | * $ref: '#/components/schemas/product_name' 30 | * price: 31 | * $ref: '#/components/schemas/price' 32 | * description: 33 | * $ref: '#/components/schemas/product_description' 34 | * quantity: 35 | * $ref: '#/components/schemas/quantity' 36 | * in_stock: 37 | * $ref: '#/components/schemas/in_stock' 38 | * category: 39 | * $ref: '#/components/schemas/category' 40 | * created: 41 | * $ref: '#/components/schemas/date_time' 42 | * modified: 43 | * $ref: '#/components/schemas/date_time' 44 | * 45 | */ 46 | 47 | /** 48 | * @swagger 49 | * /products: 50 | * get: 51 | * tags: 52 | * - Shop 53 | * summary: Returns all products 54 | * responses: 55 | * 200: 56 | * description: An array of Product objects. 57 | * content: 58 | * application/json: 59 | * schema: 60 | * type: object 61 | * properties: 62 | * products: 63 | * type: array 64 | * items: 65 | * $ref: '#/components/schemas/Product' 66 | */ 67 | router.get('/', async (req, res, next) => { 68 | try { 69 | const response = await getAll(); 70 | res.status(200).json(response); 71 | } catch(err) { 72 | next(err) 73 | } 74 | }); 75 | 76 | /** 77 | * @swagger 78 | * /products/search: 79 | * get: 80 | * tags: 81 | * - Shop 82 | * summary: Returns products in query 83 | * parameters: 84 | * - name: q 85 | * description: search query 86 | * in: query 87 | * required: true 88 | * schema: 89 | * $ref: '#/components/schemas/query' 90 | * responses: 91 | * 200: 92 | * description: An array of Product objects. 93 | * content: 94 | * application/json: 95 | * schema: 96 | * type: object 97 | * properties: 98 | * products: 99 | * type: array 100 | * items: 101 | * $ref: '#/components/schemas/Product' 102 | * 400: 103 | * $ref: '#/components/responses/InputsError' 104 | * 404: 105 | * description: No products matching search query. 106 | */ 107 | router.get('/search', async (req, res, next) => { 108 | try { 109 | const query = req.query; 110 | const response = await getSearch(query); 111 | res.status(200).json(response); 112 | } catch(err) { 113 | next(err) 114 | } 115 | }); 116 | 117 | /** 118 | * @swagger 119 | * /products/{product_id}: 120 | * get: 121 | * tags: 122 | * - Shop 123 | * summary: Returns products with associated ID 124 | * parameters: 125 | * - name: product_id 126 | * description: ID of product 127 | * in: path 128 | * required: true 129 | * schema: 130 | * $ref: '#/components/schemas/product_id' 131 | * responses: 132 | * 200: 133 | * description: A Product object. 134 | * content: 135 | * application/json: 136 | * schema: 137 | * type: object 138 | * properties: 139 | * product: 140 | * $ref: '#/components/schemas/Product' 141 | * 404: 142 | * description: Product with ID not found. 143 | */ 144 | router.get('/:product_id', async (req, res, next) => { 145 | try { 146 | const product_id = req.params.product_id 147 | const response = await getById(product_id); 148 | res.status(200).json(response); 149 | } catch(err) { 150 | next(err) 151 | } 152 | }); 153 | 154 | /** 155 | * @swagger 156 | * /products/category/{category}: 157 | * get: 158 | * tags: 159 | * - Shop 160 | * summary: Returns products in category 161 | * parameters: 162 | * - name: category 163 | * description: category to filter products 164 | * in: path 165 | * required: true 166 | * schema: 167 | * $ref: '#/components/schemas/category' 168 | * responses: 169 | * 200: 170 | * description: An array of Product objects. 171 | * content: 172 | * application/json: 173 | * schema: 174 | * type: object 175 | * properties: 176 | * products: 177 | * type: array 178 | * items: 179 | * $ref: '#/components/schemas/Product' 180 | * 404: 181 | * description: No products matching category. 182 | */ 183 | router.get('/category/:category', async (req, res, next) => { 184 | try { 185 | const category = req.params.category; 186 | const response = await getCategory(category); 187 | res.status(200).json(response); 188 | } catch(err) { 189 | next(err) 190 | } 191 | }); 192 | } -------------------------------------------------------------------------------- /tests/cart.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const session = require('supertest-session'); 3 | const { loginUser, 4 | createCSRFToken, 5 | createCartItem } = require('./testUtils'); 6 | const { user1 } = require('./testData').users; 7 | const { product } = require('./testData'); 8 | const Cart = require('../models/CartModel'); 9 | const CartItem = require('../models/CartItemModel'); 10 | 11 | 12 | describe ('Cart endpoints', () => { 13 | 14 | describe('Valid auth', () => { 15 | let cartId; 16 | let testSession; 17 | let csrfToken; 18 | 19 | beforeAll(async () => { 20 | // create test session 21 | testSession = session(app); 22 | 23 | try { 24 | // create csrfToken 25 | csrfToken = await createCSRFToken(testSession); 26 | 27 | // log user in 28 | token = await loginUser(user1, testSession, csrfToken); 29 | } catch(e) { 30 | console.log(e); 31 | } 32 | }) 33 | 34 | afterAll(async() => { 35 | try { 36 | if(cartId) { 37 | // delete cart item 38 | await CartItem.delete({ cart_id: cartId, product_id: product.product_id }); 39 | 40 | // delete cart 41 | await Cart.delete(cartId); 42 | } 43 | } catch(e) { 44 | console.log(e); 45 | } 46 | }) 47 | 48 | describe('POST \'/cart\'', () => { 49 | 50 | it ('Should create a new cart', async () => { 51 | const res = await testSession 52 | .post('/cart') 53 | .set('Accept', 'application/json') 54 | .set(`XSRF-TOKEN`, csrfToken) 55 | .expect(201); 56 | expect(res.body).toBeDefined(); 57 | expect(res.body.cart).toBeDefined(); 58 | expect(res.body.cart.id).toBeDefined(); 59 | cartId = res.body.cart.id; 60 | }) 61 | }) 62 | 63 | describe('GET \'/cart\'', () => { 64 | 65 | describe('Empty cart', () => { 66 | 67 | it('Should throw a 404 error', async () => { 68 | const res = await testSession 69 | .get(`/cart`) 70 | .set('Accept', 'application/json') 71 | .expect(404); 72 | }) 73 | }) 74 | 75 | describe('Item in cart', () => { 76 | 77 | beforeEach(async () => { 78 | // add item to cart 79 | await createCartItem(product, testSession, csrfToken); 80 | }) 81 | 82 | it('Should return the cart and cart item(s)', async () => { 83 | const res = await testSession 84 | .get(`/cart`) 85 | .set('Accept', 'application/json') 86 | .expect(200); 87 | expect(res.body).toBeDefined(); 88 | expect(res.body.cart).toBeDefined(); 89 | expect(res.body.cart.id).toEqual(cartId); 90 | expect(res.body.cartItems).toBeDefined(); 91 | expect(res.body.cartItems[0]).toBeDefined(); 92 | expect(res.body.cartItems[0].product_id).toEqual(product.product_id); 93 | }) 94 | }) 95 | }) 96 | }) 97 | 98 | describe('Invalid auth', () => { 99 | 100 | let cartId; 101 | let testSession; 102 | let csrfToken 103 | 104 | beforeAll(async () => { 105 | testSession = session(app); 106 | 107 | try { 108 | // create csrfToken 109 | csrfToken = await createCSRFToken(testSession); 110 | } catch(e) { 111 | console.log(e); 112 | } 113 | }) 114 | 115 | afterAll(async() => { 116 | try { 117 | if(cartId) { 118 | // delete cart item 119 | await CartItem.delete({ cart_id: cartId, product_id: product.product_id }); 120 | 121 | // delete cart 122 | await Cart.delete(cartId); 123 | } 124 | } catch(e) { 125 | console.log(e); 126 | } 127 | }) 128 | 129 | describe('POST \'/cart\'', () => { 130 | 131 | it ('Should create a new cart', async () => { 132 | const res = await testSession 133 | .post('/cart') 134 | .set('Accept', 'application/json') 135 | .set(`XSRF-TOKEN`, csrfToken) 136 | .expect(201); 137 | expect(res.body).toBeDefined(); 138 | expect(res.body.cart).toBeDefined(); 139 | expect(res.body.cart.id).toBeDefined(); 140 | cartId = res.body.cart.id; 141 | }) 142 | }) 143 | 144 | describe('GET \'/cart\'', () => { 145 | 146 | describe('Empty cart', () => { 147 | 148 | it('Should throw a 404 error', async () => { 149 | const res = await testSession 150 | .get(`/cart`) 151 | .set('Accept', 'application/json') 152 | .expect(404); 153 | }) 154 | }) 155 | 156 | describe('Item in cart', () => { 157 | 158 | beforeEach(async () => { 159 | // add item to cart 160 | await createCartItem(product, testSession, csrfToken); 161 | }) 162 | 163 | it('Should return the cart and cart item(s)', async () => { 164 | const res = await testSession 165 | .get(`/cart`) 166 | .set('Accept', 'application/json') 167 | .expect(200); 168 | expect(res.body).toBeDefined(); 169 | expect(res.body.cart).toBeDefined(); 170 | expect(res.body.cart.id).toEqual(cartId); 171 | expect(res.body.cartItems).toBeDefined(); 172 | expect(res.body.cartItems[0]).toBeDefined(); 173 | expect(res.body.cartItems[0].product_id).toEqual(product.product_id); 174 | }) 175 | }) 176 | }) 177 | }) 178 | }) -------------------------------------------------------------------------------- /routes/checkout/payment.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { postPayment } = require('../../services/checkoutService'); 3 | const { getAllPayments } = require('../../services/paymentService'); 4 | const { getAllAddresses } = require('../../services/addressService'); 5 | const { checkoutAuth } = require('../../lib/customAuth/jwtAuth'); 6 | 7 | module.exports = (app) => { 8 | 9 | app.use('/payment', router); 10 | 11 | /** 12 | * @swagger 13 | * /checkout/payment: 14 | * get: 15 | * tags: 16 | * - Checkout 17 | * summary: info for user to select payment method 18 | * security: 19 | * - bearerJWT: [] 20 | * - cookieJWT: [] 21 | * responses: 22 | * 200: 23 | * description: Info about user's saved payment methods 24 | * content: 25 | * application/json: 26 | * schema: 27 | * type: object 28 | * properties: 29 | * payments: 30 | * type: array 31 | * items: 32 | * $ref: '#/components/schemas/Card' 33 | * addresses: 34 | * type: array 35 | * items: 36 | * $ref: '#/components/schemas/Address' 37 | * 302: 38 | * description: | 39 | * Redirects to /cart if user is not authorized 40 | */ 41 | router.get('/', checkoutAuth, async (req, res, next) => { 42 | try { 43 | // grab user_id 44 | const user_id = req.jwt.sub; 45 | 46 | // get payments 47 | const response = await getAllPayments(user_id); 48 | 49 | response.addresses = await getAllAddresses(user_id); 50 | 51 | res.status(200).json(response); 52 | } catch(err) { 53 | next(err); 54 | } 55 | }); 56 | 57 | /** 58 | * @swagger 59 | * /checkout/payment: 60 | * post: 61 | * tags: 62 | * - Checkout 63 | * summary: User selects payment from saved info or adds new one with billing address. 64 | * produces: 65 | * - application/json 66 | * security: 67 | * - bearerJWT: [] 68 | * - cookieJWT: [] 69 | * requestBody: 70 | * description: body with necessary parameters 71 | * required: true 72 | * content: 73 | * application/json: 74 | * schema: 75 | * oneOf: 76 | * - type: object 77 | * properties: 78 | * payment_id: 79 | * $ref: '#/components/schemas/id' 80 | * - type: object 81 | * properties: 82 | * address_id: 83 | * $ref: '#/components/schemas/id' 84 | * provider: 85 | * $ref: '#/components/schemas/provider' 86 | * card_type: 87 | * $ref: '#/components/schemas/card_type' 88 | * card_no: 89 | * $ref: '#/components/schemas/card_no' 90 | * exp_month: 91 | * $ref: '#/components/schemas/exp_month' 92 | * exp_year: 93 | * $ref: '#/components/schemas/exp_year' 94 | * cvv: 95 | * $ref: '#/components/schemas/cvv' 96 | * is_primary_payment: 97 | * $ref: '#/components/schemas/is_primary_payment' 98 | * - type: object 99 | * properties: 100 | * address1: 101 | * $ref: '#/components/schemas/address1' 102 | * address2: 103 | * $ref: '#/components/schemas/address2' 104 | * city: 105 | * $ref: '#/components/schemas/city' 106 | * state: 107 | * $ref: '#/components/schemas/state' 108 | * zip: 109 | * $ref: '#/components/schemas/zip' 110 | * country: 111 | * $ref: '#/components/schemas/country' 112 | * first_name: 113 | * $ref: '#/components/schemas/first_name' 114 | * last_name: 115 | * $ref: '#/components/schemas/last_name' 116 | * is_primary_address: 117 | * $ref: '#/components/schemas/is_primary_address' 118 | * provider: 119 | * $ref: '#/components/schemas/provider' 120 | * card_type: 121 | * $ref: '#/components/schemas/card_type' 122 | * card_no: 123 | * $ref: '#/components/schemas/card_no' 124 | * exp_month: 125 | * $ref: '#/components/schemas/exp_month' 126 | * exp_year: 127 | * $ref: '#/components/schemas/exp_year' 128 | * cvv: 129 | * $ref: '#/components/schemas/cvv' 130 | * is_primary_payment: 131 | * $ref: '#/components/schemas/is_primary_payment' 132 | * responses: 133 | * 302: 134 | * description: | 135 | * Redirects to checkout/order if payment info input. 136 | * Redirects to checkout/payment if inputs invalid. 137 | * Redirects to /cart if user not authenticated. 138 | */ 139 | router.post('/', checkoutAuth, async (req, res, next) => { 140 | try { 141 | // grab data needed for checkout 142 | const data = { 143 | ...req.body, 144 | user_id: req.jwt.sub, 145 | }; 146 | 147 | // await response 148 | const response = await postPayment(data); 149 | 150 | // attach billing info to session 151 | req.session.billing_address_id = response.billing.id; 152 | 153 | // attach payment method to session 154 | req.session.payment_id = response.payment.id; 155 | 156 | // redirect to get order review 157 | res.redirect(`/checkout/order`); 158 | } catch(err) { 159 | if (err.status === 400) { 160 | res.redirect('/checkout/payment'); 161 | } 162 | next(err); 163 | } 164 | }); 165 | } -------------------------------------------------------------------------------- /routes/checkout/auth.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { login, register } = require('../../services/authService'); 3 | const { demiAuth } = require('../../lib/customAuth/jwtAuth'); 4 | const { JWTcookieOptions } = require('../../lib/customAuth/attachJWT'); 5 | 6 | module.exports = (app) => { 7 | 8 | app.use('/auth', router); 9 | 10 | /** 11 | * @swagger 12 | * /checkout/auth: 13 | * get: 14 | * tags: 15 | * - Checkout 16 | * summary: Returns login/registration forms 17 | * responses: 18 | * 200: 19 | * description: Login and/or registration forms. 20 | */ 21 | router.get('/', demiAuth, (req, res, next) => { 22 | res.status(200).json('Login and/or Register Checkout forms go here.'); 23 | }); 24 | 25 | /** 26 | * @swagger 27 | * /checkout/auth/login: 28 | * post: 29 | * tags: 30 | * - Checkout 31 | * summary: Logs user into account and attaches authentication 32 | * requestBody: 33 | * description: body with necessary parameters 34 | * required: true 35 | * content: 36 | * application/json: 37 | * schema: 38 | * type: object 39 | * properties: 40 | * email: 41 | * $ref: '#/components/schemas/email' 42 | * password: 43 | * $ref: '#/components/schemas/password' 44 | * required: 45 | * - email 46 | * - password 47 | * parameters: 48 | * - name: cart_id 49 | * description: ID associated with Cart 50 | * in: cookie 51 | * required: true 52 | * schema: 53 | * $ref: '#/components/schemas/id' 54 | * responses: 55 | * 302: 56 | * description: Redirects to shipping when user is logged in. Redirects to auth if login fails. 57 | * headers: 58 | * Set-Cookie: 59 | * schema: 60 | * type: string 61 | * example: access_token=eyJhbGc...; Path=/; HttpOnly; Secure 62 | */ 63 | router.post('/login', demiAuth, async (req, res, next) => { 64 | try { 65 | // check if user is already logged in 66 | if (req.jwt && req.jwt.sub) { 67 | // redirect to shipping if user is logged in 68 | res.redirect('/checkout/shipping'); 69 | } else { 70 | 71 | // grab necessary data 72 | const data = { 73 | email: req.body.email, 74 | password: req.body.password, 75 | cart_id: req.session.cart_id || null 76 | } 77 | 78 | // await response 79 | const response = await login(data); 80 | 81 | // attach cart_id to session, in case cart_id changed in cart consolidation 82 | if (response.cart && data.cart_id !== response.cart.id) { 83 | req.session.cart_id = response.cart.id; 84 | } 85 | 86 | // add JWT to header 87 | res.header('Authorization', response.token); 88 | 89 | // attach JWT cookie and redirect to get shipping info 90 | res.cookie("access_token", response.token, JWTcookieOptions).redirect('/checkout/shipping'); 91 | } 92 | } catch(err) { 93 | if (err.status === 400 || err.status === 401) { 94 | res.redirect('/checkout/auth'); 95 | } 96 | next(err); 97 | } 98 | }); 99 | 100 | /** 101 | * @swagger 102 | * /checkout/auth/register: 103 | * post: 104 | * tags: 105 | * - Checkout 106 | * summary: Registers user account and attaches authentication 107 | * requestBody: 108 | * description: body with necessary parameters 109 | * required: true 110 | * content: 111 | * application/json: 112 | * schema: 113 | * type: object 114 | * properties: 115 | * email: 116 | * $ref: '#/components/schemas/email' 117 | * password: 118 | * $ref: '#/components/schemas/password' 119 | * required: 120 | * - email 121 | * - password 122 | * parameters: 123 | * - name: cart_id 124 | * description: ID associated with Cart 125 | * in: cookie 126 | * required: true 127 | * schema: 128 | * $ref: '#/components/schemas/id' 129 | * responses: 130 | * 302: 131 | * description: Redirects to shipping when user is logged in. Redirects to auth if registration fails. 132 | * headers: 133 | * Set-Cookie: 134 | * schema: 135 | * type: string 136 | * example: access_token=eyJhbGc...; Path=/; HttpOnly; Secure 137 | */ 138 | router.post('/register', demiAuth, async (req, res, next) => { 139 | try { 140 | // check if user is already logged in 141 | if (req.jwt && req.jwt.sub) { 142 | // redirect to shipping if user is logged in 143 | res.redirect('/checkout/shipping'); 144 | } else { 145 | 146 | // grab necessary data 147 | const data = { 148 | email: req.body.email, 149 | password: req.body.password, 150 | cart_id: req.session.cart_id || null 151 | } 152 | 153 | // await response 154 | const response = await register(data/*{ ...req.body, cart_id: cart_id }*/); 155 | 156 | // attach cart_id to session, in case cart_id changed in cart consolidation 157 | if (response.cart && data.cart_id !== response.cart.id) { 158 | req.session.cart_id = response.cart.id; 159 | } 160 | 161 | // add JWT to header 162 | res.header('Authorization', response.token); 163 | 164 | // attach cookie and redirect to get shipping info 165 | res.cookie("access_token", response.token, JWTcookieOptions).redirect('/checkout/shipping'); 166 | } 167 | } catch(err) { 168 | if (err.status === 400 || err.status === 409) { 169 | res.redirect('/checkout/auth'); 170 | } 171 | next(err); 172 | } 173 | }); 174 | } -------------------------------------------------------------------------------- /tests/account.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const session = require('supertest-session'); 3 | const { loginUser, 4 | createCSRFToken } = require('./testUtils'); 5 | const { user1 } = require('./testData').users; 6 | const { userAccountPut, 7 | xssAttack } = require('./testData'); 8 | const User = require('../models/UserModel'); 9 | const Address = require('../models/AddressModel'); 10 | const Card = require('../models/CardModel'); 11 | 12 | describe ('Account endpoints', () => { 13 | 14 | describe('Valid auth', () => { 15 | 16 | let testSession; 17 | let csrfToken; 18 | let token; 19 | 20 | beforeAll(async () => { 21 | // create a test session for saving functional cookies 22 | testSession = session(app); 23 | 24 | try { 25 | // create csrfToken 26 | csrfToken = await createCSRFToken(testSession); 27 | 28 | // log user in 29 | token = await loginUser(user1, testSession, csrfToken); 30 | } catch(e) { 31 | console.log(e); 32 | } 33 | }) 34 | 35 | describe('GET \'/account\'', () => { 36 | 37 | describe('JWT in cookie', () => { 38 | 39 | it ('Should return user info', async () => { 40 | const res = await testSession 41 | .get('/account') 42 | .set('Accept', 'application/json') 43 | .expect('Content-Type', /json/) 44 | .expect(200); 45 | expect(res.body).toBeDefined(); 46 | expect(res.body.user).toBeDefined(); 47 | expect(res.body.user.id).toEqual(user1.id); 48 | expect(res.body.user.email).toEqual(user1.email); 49 | }) 50 | }) 51 | 52 | describe('JWT in bearer token', () => { 53 | 54 | it ('Should return user info', async () => { 55 | const res = await testSession 56 | .get('/account') 57 | .set('Authorization', token) 58 | .set('Accept', 'application/json') 59 | .expect('Content-Type', /json/) 60 | .expect(200); 61 | expect(res.body).toBeDefined(); 62 | expect(res.body.user).toBeDefined(); 63 | expect(res.body.user.id).toEqual(user1.id); 64 | expect(res.body.user.email).toEqual(user1.email); 65 | }) 66 | }) 67 | }) 68 | 69 | describe('PUT \'/account/\'', () => { 70 | 71 | describe('JWT in cookie', () => { 72 | 73 | describe('Put that updates first_name', () => { 74 | 75 | it ('Should return user info', async () => { 76 | const res = await testSession 77 | .put(`/account/`) 78 | .send(userAccountPut) 79 | .set('Accept', 'application/json') 80 | .set(`XSRF-TOKEN`, csrfToken) 81 | .expect('Content-Type', /json/) 82 | .expect(200); 83 | expect(res.body).toBeDefined(); 84 | expect(res.body.user).toBeDefined(); 85 | expect(res.body.user.id).toEqual(user1.id); 86 | expect(res.body.user.email).toEqual(user1.email); 87 | expect(res.body.user.first_name).toEqual(userAccountPut.first_name); 88 | }) 89 | }) 90 | }) 91 | 92 | describe('XSS attack on first_name field', () => { 93 | 94 | it ('Should be return 400 because escaped XSS attack is longer than characters permitted', (done) => { 95 | testSession 96 | .put(`/account/`) 97 | .send({...userAccountPut, 98 | first_name: xssAttack}) 99 | .set('Accept', 'application/json') 100 | .set(`XSRF-TOKEN`, csrfToken) 101 | .expect(400) 102 | .end((err, res) => { 103 | if (err) return done(err); 104 | return done(); 105 | }); 106 | }) 107 | }) 108 | 109 | describe('XSS attack on last_name field', () => { 110 | 111 | it ('Should be return 400 because escaped XSS attack is longer than characters permitted', (done) => { 112 | testSession 113 | .put(`/account/`) 114 | .send({...userAccountPut, 115 | last_name: xssAttack}) 116 | .set('Accept', 'application/json') 117 | .set(`XSRF-TOKEN`, csrfToken) 118 | .expect(400) 119 | .end((err, res) => { 120 | if (err) return done(err); 121 | return done(); 122 | }); 123 | }) 124 | }) 125 | }) 126 | }) 127 | 128 | describe('Invalid auth', () => { 129 | 130 | let testSession; 131 | let csrfToken; 132 | 133 | beforeAll(async () => { 134 | // create a test session for saving functional cookies 135 | testSession = session(app); 136 | 137 | try { 138 | // create csrfToken 139 | csrfToken = await createCSRFToken(testSession); 140 | } catch(e) { 141 | console.log(e); 142 | } 143 | }) 144 | 145 | describe('GET \'/account\'', () => { 146 | 147 | it ('Should return 401 error', (done) => { 148 | testSession 149 | .get('/account') 150 | .set('Accept', 'application/json') 151 | .expect(401) 152 | .end((err, res) => { 153 | if (err) return done(err); 154 | return done(); 155 | }); 156 | }) 157 | }) 158 | 159 | describe('PUT \'/account/\'', () => { 160 | 161 | it ('Should return 401 error', (done) => { 162 | testSession 163 | .put(`/account/`) 164 | .send(userAccountPut) 165 | .set('Accept', 'application/json') 166 | .set(`XSRF-TOKEN`, csrfToken) 167 | .expect(401) 168 | .end((err, res) => { 169 | if (err) { 170 | console.log(err); 171 | return done(err); 172 | } 173 | return done(); 174 | }); 175 | }) 176 | }) 177 | }) 178 | }) -------------------------------------------------------------------------------- /models/OrderModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class Order { 4 | 5 | /** 6 | * Adds new order to the database 7 | * 8 | * @param {Object} data data about the order 9 | * @return {Oject|null} The new order 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `WITH items AS ( 15 | SELECT 16 | order_id, 17 | SUM(quantity) AS "num_items" 18 | FROM order_items 19 | WHERE order_id = $1 20 | GROUP BY order_id 21 | ), new_order AS ( 22 | INSERT INTO orders (user_id, 23 | shipping_address_id, 24 | billing_address_id, 25 | payment_id, 26 | amount_charged, 27 | stripe_charge_id) 28 | VALUES ($1, $2, $3, $4, $5, $6) 29 | RETURNING * 30 | ) 31 | SELECT 32 | new_order.*, 33 | CASE WHEN items.num_items IS NULL THEN 0 ELSE items.num_items::integer END 34 | FROM new_order 35 | LEFT JOIN items 36 | ON new_order.id = items.order_id`; 37 | 38 | // ph values 39 | const values = [data.user_id, 40 | data.shipping_address_id, 41 | data.billing_address_id, 42 | data.payment_id, 43 | data.amount_charged, 44 | data.stripe_charge_id] 45 | 46 | // make query 47 | const result = await db.query(statement, values); 48 | 49 | // check for valid results 50 | if (result.rows.length > 0) { 51 | return result.rows[0]; 52 | } else { 53 | return null; 54 | } 55 | } catch(err) { 56 | throw new Error(err); 57 | } 58 | } 59 | 60 | /** 61 | * Returns order associated with id in database, if exists 62 | * 63 | * @param {number} id the id to find order based on 64 | * @return {Object|null} the order 65 | */ 66 | async findById(id) { 67 | try { 68 | // pg statement 69 | const statement = `WITH items AS ( 70 | SELECT 71 | order_id, 72 | SUM(quantity) AS "num_items" 73 | FROM order_items 74 | WHERE order_id = $1 75 | GROUP BY order_id 76 | ) 77 | SELECT 78 | orders.*, 79 | CASE WHEN items.num_items IS NULL THEN 0 ELSE items.num_items::integer END 80 | FROM orders 81 | LEFT JOIN items 82 | ON orders.id = items.order_id 83 | WHERE orders.id = $1`; 84 | 85 | // make query 86 | const result = await db.query(statement, [id]); 87 | 88 | // check for valid results 89 | if (result.rows.length > 0) { 90 | return result.rows[0]; 91 | } else { 92 | return null; 93 | } 94 | } catch(err) { 95 | throw new Error(err); 96 | } 97 | } 98 | 99 | /** 100 | * Returns orders associated with user_id in database, if exists 101 | * 102 | * @param {number} user_id the user_id to find orders based on 103 | * @return {Array|null} the orders 104 | */ 105 | async findByUserId(user_id) { 106 | try { 107 | // pg statement 108 | const statement = `WITH items AS ( 109 | SELECT 110 | order_id, 111 | SUM(quantity) AS "num_items" 112 | FROM order_items 113 | GROUP BY order_id 114 | ) 115 | SELECT 116 | orders.*, 117 | CASE WHEN items.num_items IS NULL THEN 0 ELSE items.num_items::integer END 118 | FROM orders 119 | LEFT JOIN items 120 | ON orders.id = items.order_id 121 | WHERE orders.user_id = $1`; 122 | 123 | // make query 124 | const result = await db.query(statement, [user_id]); 125 | 126 | // check for valid results 127 | if (result.rows.length > 0) { 128 | return result.rows; 129 | } else { 130 | return null; 131 | } 132 | } catch(err) { 133 | throw new Error(err); 134 | } 135 | } 136 | 137 | /** 138 | * Deletes order associated with id in database, if exists 139 | * 140 | * @param {number} id the id to delete order based on 141 | * @return {Object|null} the order 142 | */ 143 | async delete(id) { 144 | try { 145 | // pg statement 146 | const statement = `WITH items AS ( 147 | SELECT 148 | order_id, 149 | SUM(quantity) AS "num_items" 150 | FROM order_items 151 | GROUP BY order_id 152 | ), deleted_order AS ( 153 | DELETE FROM orders 154 | WHERE id=$1 155 | RETURNING * 156 | ) 157 | SELECT 158 | deleted_order.*, 159 | CASE WHEN items.num_items IS NULL THEN 0 ELSE items.num_items::integer END 160 | FROM deleted_order 161 | LEFT JOIN items 162 | ON deleted_order.id = items.order_id`; 163 | 164 | // make query 165 | const result = await db.query(statement, [id]); 166 | 167 | // check for valid results 168 | if (result.rows.length > 0) { 169 | return result.rows[0]; 170 | } else { 171 | return null; 172 | } 173 | } catch(err) { 174 | throw new Error(err); 175 | } 176 | } 177 | } 178 | 179 | module.exports = new Order(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # E-Commerce Backend 2 | > Backend API for an e-commerce site built with Node.js/Express and Postgres. 3 | > Live API docs [_here_](https://crk-e-commerce.herokuapp.com/api-docs/). 4 | 5 | ## Table of Contents 6 | * [General Info](#general-information) 7 | * [Technologies Used](#technologies-used) 8 | * [Features](#features) 9 | * [Setup](#setup) 10 | * [Testing](#testing) 11 | * [Usage](#usage) 12 | * [Project Status](#project-status) 13 | * [Room for Improvement](#room-for-improvement) 14 | * [Acknowledgements](#acknowledgements) 15 | * [Contact](#contact) 16 | 17 | 18 | ## General Information 19 | - Backend API for an e-commerce site built with Node.js and Express 20 | - Endpoints for shopping, authentication, user accounts, and checkout 21 | - Provides setup for Postgres database 22 | - Built as a learning project, feedback appreciated! 23 | 24 | 25 | 26 | ## Features 27 | #### Shopping 28 | - Shopping routes that allow shoppers to browse by category or search for products 29 | - Persistent carts that consolidate when user logs-in/registers so shopping data is not lost 30 | #### User Accounts 31 | - Users can create an account to save shopping session and view information about their orders 32 | - Allow users to store addresses and payment methods, and set a primary address and primary payment method 33 | #### Fully-Built Checkout Flow 34 | - User can use saved payment methods and addresses, or enter new ones at checkout 35 | - Checkout route provides a review page before placing order 36 | - Payment processing with Stripe API 37 | #### Security 38 | - Custom hashing function for passwords using bcrypt and a salt 39 | - Custom RSA authentication middleware using secure JWT Bearer Tokens to protect against CSRF 40 | - Custom data sanitizer and validation for protection against XSS attacks 41 | - Parameterized queries to protect against SQL injection 42 | #### Testing 43 | - Thorough test suite with multiple tests for each route 44 | - End-to-end tests for the checkout flow 45 | - `pre-test` and `post-test` scripts to automate testing setup and tear down 46 | #### API Documentation 47 | - Documentation with Swagger UI 48 | - Can try out endpoints with test data via Swagger UI, connected to a test database 49 | - Parameters, request body, and response options are documented for each endpoint 50 | - Can create an account and authorize to access all endpoints via Swagger UI 51 | 52 | 53 | ## Technologies Used 54 | #### Server 55 | - `node.js` - version 14.17.1 56 | - `npm` - version 8.3.0 57 | - `express` - version 4.17.1 58 | - `express-session` - version 1.17.2 59 | - `http-errors` - version 1.8.1 60 | - `jsonwebtoken` - version 8.5.1 61 | - `stripe` - version 8.195.0 62 | - `validator` - version 13.7.0 63 | - `nodemon` - version 2.0.15 64 | - `body-parser` - version 1.19.0 65 | - `cookie-parser` - version 1.4.6 66 | - `cors` - version 2.8.5 67 | - `helmet` - version 4.6.0 68 | 69 | #### Database 70 | - `psql` (PostgreSQL) - version 14.0 71 | - `connect-pg-simple` - version 7.0.0 72 | - `pg` (node-postgres) - version 8.7.1 73 | 74 | #### Documentation 75 | - `swagger-jsdoc` - version 1.3.0 76 | - `swagger-ui-express` - version 4.3.0 77 | 78 | #### Testing 79 | - `jest` - version 27.4.3 80 | - `supertest` - version 6.1.6 81 | - `supertest-session` - version 4.1.0 82 | 83 | 84 | ## Setup 85 | To run locally, first install node_modules and generate RSA Key Pair: 86 | 87 | ``` 88 | npm install 89 | ``` 90 | Will also run `install ` script of `package.json`, which will generate an RSA key pair in a `.env` file. 91 | 92 | Open a PostgreSQL database of your choice. Schema with tables is located in `db/init.sql`. E.g., generate tables by running: 93 | ``` 94 | cd db 95 | cat init.sql | psql -h [PGHOST] -U [PGUSER] -d [PGDATABASE] -w [PGPASSWORD] 96 | ``` 97 | Where 'PGHOST', 'PGUSER', 'PGDATABASE', and 'PGPASSWORD' are your respective Postgres host, user, database, and password values. 98 | 99 | Add the following fields with respective values to the `.env` file: 100 | 101 | ``` 102 | # Postgres Database 103 | PGHOST= 104 | PGUSER= 105 | PGDATABASE= 106 | PGPASSWORD= 107 | PGPORT= 108 | 109 | # Express server 110 | PORT= 111 | SESSION_SECRET= 112 | 113 | # Node.js 114 | NODE_ENV= 115 | 116 | # Stripe key pair 117 | STRIPE_PUBLIC_KEY= 118 | STRIPE_SECRET_KEY= 119 | ``` 120 | Create an account with Stripe to generate a key pair. 121 | Can use a test key pair for development that will not charge cards. 122 | 123 | Then run the app: 124 | 125 | ``` 126 | node index.js 127 | ``` 128 | 129 | ## Testing 130 | 131 | To run tests, make sure database is set up (run `db/init.sql`) and `.env` contains is completed as described above. 132 | 133 | Once test data is added, run: 134 | ``` 135 | npm test 136 | ``` 137 | *Note*: `npm test` script will also run `npm pretest` and `npm posttest` which include scripts for setting up and tearing down test data in database. 138 | 139 | ## Usage 140 | This project can be used as a backend for an e-commerce website. 141 | The project handles various endpoints a user may need to access while online shopping such as: 142 | - creating user accounts 143 | - users can save addresses and payment methods to account 144 | - displaying products and allowing query by parameter 145 | - creating carts, and consolidating carts when a user logs in 146 | - checkout flow and charging payments with Stripe 147 | - order summaries accessed through user account 148 | 149 | __Note:__ Must use HTTPS with JWT Bearer Authentication 150 | See [Swagger API Documentation](https://crk-e-commerce.herokuapp.com/api-docs/) for info routes and their variable requirements. 151 | 152 | ### Custom JWT Authentication 153 | This project includes custom JWT authentication found in `lib/customAuth` folder instead of using Passport. 154 | 155 | The custom auth can be used either in Bearer Token or Cookie format, it will validate both formats. The API will automatically send both the Bearer Token and Cookie in response to logging in/registering. 156 | 157 | To use cookies, simply ignore the Bearer Token in the body of the response. 158 | 159 | If you wish to exclude the cookies from the response when using Bearer Tokens, remove the code attaching the cookie to the response in the auth/login, auth/register, checkout/auth routes. s 160 | 161 | 162 | ## Project Status 163 | ___IN PROGRESS:__ Working on additional secutiry measures_ 164 | 165 | ## Room for Improvement 166 | 167 | Room for improvement: 168 | - Encryption of data in database 169 | - Add more indexes to the database for faster queries 170 | 171 | To do: 172 | - Allow guest checkout flow 173 | - Send confirmation email after POSTing order 174 | - Build demo frontend site 175 | 176 | 177 | ## Acknowledgements 178 | This project was built as part of Codecademy's Full-Stack Engineer curriculum. 179 | Project prompt of creating an e-commerce backend was provided by Codecademy as well as 180 | some helpful resources for using Express and Postgres. No starter code was provided. 181 | 182 | Thanks to [@zachgoll](https://github.com/zachgoll) for his **very thorough** User Authentication tutorial for working with Passport.js and building custom authentication software. 183 | Full tutorial can be found [here](https://www.youtube.com/watch?v=F-sFp_AvHc8&list=WL&index=4&t=20087s). 184 | 185 | 186 | ## Contact 187 | Created by [@carokrny](https://carolynkearney.me) - feel free to contact me! 188 | -------------------------------------------------------------------------------- /models/UserModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class User { 4 | 5 | /** 6 | * Adds new user to the database 7 | * 8 | * @param {Object} data Data about user 9 | * @return {Oject|null} The new user 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `INSERT INTO users (email, pw_hash, pw_salt) 15 | VALUES ($1, $2, $3) 16 | RETURNING *`; 17 | 18 | // pg values 19 | const values = [data.email, data.hash, data.salt]; 20 | 21 | // make query 22 | const result = await db.query(statement, values); 23 | 24 | // check for valid results 25 | if (result.rows.length > 0) { 26 | return result.rows[0]; 27 | } else { 28 | return null; 29 | } 30 | } catch(err) { 31 | throw new Error(err); 32 | } 33 | } 34 | 35 | 36 | /** 37 | * Updates a user in the database 38 | * 39 | * @param {Obj} data Data about user to update 40 | * @return {Oject|null} The updated user 41 | */ 42 | async update(data) { 43 | try { 44 | // pg statement 45 | const statement = `UPDATE users 46 | SET email=$2, 47 | pw_hash=$3, 48 | pw_salt=$4, 49 | first_name=$5, 50 | last_name=$6, 51 | modified=now() 52 | WHERE id = $1 53 | RETURNING *`; 54 | 55 | // pg values 56 | const values = [data.id, 57 | data.email, 58 | data.pw_hash, 59 | data.pw_salt, 60 | data.first_name, 61 | data.last_name]; 62 | 63 | // make query 64 | const result = await db.query(statement, values); 65 | 66 | // check for valid results 67 | if (result.rows.length > 0) { 68 | return result.rows[0]; 69 | } else { 70 | return null; 71 | } 72 | } catch(err) { 73 | throw new Error(err); 74 | } 75 | } 76 | 77 | /** 78 | * Updates a user's primary_address_id in the database 79 | * 80 | * @param {Obj} data Data about user to update 81 | * @return {Oject|null} The updated user 82 | */ 83 | async updatePrimaryAddressId(data) { 84 | try { 85 | // pg statement 86 | const statement = `UPDATE users 87 | SET 88 | primary_address_id=$2, 89 | modified=now() 90 | WHERE id = $1 91 | RETURNING *`; 92 | 93 | // pg values 94 | const values = [data.id, 95 | data.primary_address_id]; 96 | 97 | // make query 98 | const result = await db.query(statement, values); 99 | 100 | // check for valid results 101 | if (result.rows.length > 0) { 102 | return result.rows[0]; 103 | } else { 104 | return null; 105 | } 106 | } catch(err) { 107 | throw new Error(err); 108 | } 109 | } 110 | 111 | /** 112 | * Updates a user's primary_payment_id in the database 113 | * 114 | * @param {Obj} data Data about user to update 115 | * @return {Oject|null} The updated user 116 | */ 117 | async updatePrimaryPaymentId(data) { 118 | try { 119 | // pg statement 120 | const statement = `UPDATE users 121 | SET 122 | primary_payment_id=$2, 123 | modified=now() 124 | WHERE id = $1 125 | RETURNING *`; 126 | 127 | // pg values 128 | const values = [data.id, 129 | data.primary_payment_id]; 130 | 131 | // make query 132 | const result = await db.query(statement, values); 133 | 134 | // check for valid results 135 | if (result.rows.length > 0) { 136 | return result.rows[0]; 137 | } else { 138 | return null; 139 | } 140 | } catch(err) { 141 | throw new Error(err); 142 | } 143 | } 144 | 145 | /** 146 | * Returns user associated with email in database, if exists 147 | * 148 | * @param {string} email the email to find user based on 149 | * @return {Object|null} the user 150 | */ 151 | async findByEmail(email) { 152 | try { 153 | // pg statement 154 | const statement = `SELECT * 155 | FROM users 156 | WHERE email = $1`; 157 | 158 | // make query 159 | const result = await db.query(statement, [email]); 160 | 161 | // check for valid results 162 | if (result.rows.length > 0) { 163 | return result.rows[0]; 164 | } else { 165 | return null; 166 | } 167 | } catch(err) { 168 | throw new Error(err); 169 | } 170 | } 171 | 172 | /** 173 | * Returns user associated with id in database, if exists 174 | * 175 | * @param {number} id the id to find user based on 176 | * @return {Object|null} the user 177 | */ 178 | async findById(id) { 179 | try { 180 | // pg statement 181 | const statement = `SELECT * 182 | FROM users 183 | WHERE id = $1`; 184 | 185 | // make query 186 | const result = await db.query(statement, [id]); 187 | 188 | // check for valid results 189 | if (result.rows.length > 0) { 190 | return result.rows[0]; 191 | } else { 192 | return null; 193 | } 194 | } catch(err) { 195 | throw new Error(err); 196 | } 197 | } 198 | 199 | /** 200 | * Deletes user associated with email in database, if exists 201 | * For use with testing, not for use with production. 202 | * 203 | * @param {string} email the email to delete user based on 204 | * @return {Object|null} the user 205 | */ 206 | async deleteByEmail(email) { 207 | try { 208 | // pg statement 209 | const statement = `DELETE FROM users 210 | WHERE email=$1 211 | RETURNING *` 212 | 213 | // make query 214 | const result = await db.query(statement, [email]); 215 | 216 | // check for valid results 217 | if (result.rows.length > 0) { 218 | return result.rows[0]; 219 | } else { 220 | return null; 221 | } 222 | } catch(err) { 223 | throw new Error(err); 224 | } 225 | } 226 | } 227 | 228 | module.exports = new User(); -------------------------------------------------------------------------------- /routes/checkout/order.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { wipeCardData } = require('../../lib/formatUtils'); 3 | const { getOneOrder } = require('../../services/orderService'); 4 | const { checkoutAuth } = require('../../lib/customAuth/jwtAuth'); 5 | const { getCheckoutReview, postCheckout } = require('../../services/checkoutService'); 6 | 7 | module.exports = (app) => { 8 | 9 | app.use('/order', router); 10 | 11 | /** 12 | * @swagger 13 | * /checkout/order: 14 | * get: 15 | * tags: 16 | * - Checkout 17 | * summary: Returns review before placing order. 18 | * security: 19 | * - bearerJWT: [] 20 | * - cookieJWT: [] 21 | * parameters: 22 | * - name: cart_id 23 | * description: user's shopping cart id 24 | * in: cookie 25 | * required: true 26 | * schema: 27 | * $ref: '#/components/schemas/id' 28 | * - name: shipping_address_id 29 | * description: shipping address id 30 | * in: cookie 31 | * required: true 32 | * schema: 33 | * $ref: '#/components/schemas/id' 34 | * - name: billing_address_id 35 | * description: billing address id 36 | * in: cookie 37 | * required: true 38 | * schema: 39 | * $ref: '#/components/schemas/id' 40 | * - name: payment_id 41 | * description: payment method info id 42 | * in: cookie 43 | * required: true 44 | * schema: 45 | * $ref: '#/components/schemas/id' 46 | * responses: 47 | * 200: 48 | * description: Review of cart, payment method, billing & shipping addresses. 49 | * content: 50 | * application/json: 51 | * schema: 52 | * type: object 53 | * properties: 54 | * cart: 55 | * $ref: '#/components/schemas/Cart' 56 | * cartItems: 57 | * type: array 58 | * items: 59 | * $ref: '#/components/schemas/Cart' 60 | * shipping: 61 | * $ref: '#/components/schemas/Address' 62 | * billing: 63 | * $ref: '#/components/schemas/Address' 64 | * payment: 65 | * $ref: '#/components/schemas/Card' 66 | * 302: 67 | * description: | 68 | * Redirects to /cart if user is not authenticated. 69 | * Redirects to /checkout if bad request. 70 | */ 71 | router.get('/', checkoutAuth, async (req, res, next) => { 72 | try { 73 | // grab ids needed to get checkout review 74 | const data = { 75 | cart_id: req.session.cart_id || null, 76 | shipping_address_id: req.session.shipping_address_id || null, 77 | billing_address_id: req.session.billing_address_id || null, 78 | payment_id: req.session.payment_id || null, 79 | user_id: req.jwt.sub 80 | }; 81 | 82 | // get review of checkout 83 | const response = await getCheckoutReview(data); 84 | 85 | // wipe sensitive payment data 86 | wipeCardData(response.payment); 87 | 88 | res.status(200).json(response); 89 | 90 | } catch(err) { 91 | if(err.status === 400) { 92 | // redirect if required session info is missing 93 | res.redirect('/cart/checkout'); 94 | } 95 | next(err); 96 | } 97 | 98 | }); 99 | 100 | /** 101 | * @swagger 102 | * /checkout/order: 103 | * post: 104 | * tags: 105 | * - Checkout 106 | * summary: Submits order. 107 | * security: 108 | * - bearerJWT: [] 109 | * - cookieJWT: [] 110 | * parameters: 111 | * - name: cart_id 112 | * description: user's shopping cart id 113 | * in: cookie 114 | * required: true 115 | * schema: 116 | * $ref: '#/components/schemas/id' 117 | * - name: shipping_address_id 118 | * description: shipping address id 119 | * in: cookie 120 | * required: true 121 | * schema: 122 | * $ref: '#/components/schemas/id' 123 | * - name: billing_address_id 124 | * description: billing address id 125 | * in: cookie 126 | * required: true 127 | * schema: 128 | * $ref: '#/components/schemas/id' 129 | * - name: payment_id 130 | * description: payment method info id 131 | * in: cookie 132 | * required: true 133 | * schema: 134 | * $ref: '#/components/schemas/id' 135 | * responses: 136 | * 302: 137 | * description: | 138 | * Redirects to /checkout/order/confirmation if sucessful. 139 | * Redirects to /cart if user is not autheticated. 140 | * Redirects to /checkout if bad request 141 | */ 142 | router.post('/', checkoutAuth, async (req, res, next) => { 143 | try { 144 | // grab ids needed to get checkout summary 145 | const data = { 146 | cart_id: req.session.cart_id || null, 147 | shipping_address_id: req.session.shipping_address_id || null, 148 | billing_address_id: req.session.billing_address_id || null, 149 | payment_id: req.session.payment_id || null, 150 | user_id: req.jwt.sub 151 | }; 152 | 153 | // create order 154 | const response = await postCheckout(data); 155 | 156 | // attach order_id to session 157 | req.session.order_id = response.order.id; 158 | 159 | // redirect to get order review 160 | res.redirect(`/checkout/order/confirmation`); 161 | } catch(err) { 162 | if(err.status === 400) { 163 | // redirect if required session info is missing 164 | res.redirect('/cart/checkout'); 165 | } 166 | next(err); 167 | } 168 | }); 169 | 170 | /** 171 | * @swagger 172 | * /checkout/order/confirmation: 173 | * get: 174 | * tags: 175 | * - Checkout 176 | * summary: Returns order confirmation 177 | * security: 178 | * - bearerJWT: [] 179 | * - cookieJWT: [] 180 | * parameters: 181 | * - name: order_id 182 | * description: ID of order 183 | * in: cookie 184 | * required: true 185 | * type: string 186 | * responses: 187 | * 200: 188 | * description: An Order object. 189 | * content: 190 | * application/json: 191 | * schema: 192 | * type: object 193 | * properties: 194 | * order: 195 | * $ref: '#/components/schemas/Order' 196 | * 302: 197 | * description: Redirects to /cart if user is not authenticated. 198 | * 400: 199 | * description: Missing order_id. 200 | * 403: 201 | * description: User not associated with specified order_id. 202 | * 404: 203 | * description: No order found with specified order_id. 204 | */ 205 | router.get('/confirmation', checkoutAuth, async (req, res, next) => { 206 | try { 207 | const data = { 208 | user_id: req.jwt.sub, 209 | order_id: req.session.order_id || null 210 | }; 211 | 212 | // query database to confirm order went through 213 | const response = await getOneOrder(data); 214 | 215 | res.status(200).json(response); 216 | } catch(err) { 217 | next(err); 218 | } 219 | }); 220 | 221 | } -------------------------------------------------------------------------------- /models/CartItemModel.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | class CartItem { 4 | 5 | /** 6 | * Adds new cart item to the database 7 | * 8 | * @param {Object} data Contains data about new cart item 9 | * @return {Oject} The new cart item 10 | */ 11 | async create(data) { 12 | try { 13 | // pg statement 14 | const statement = `WITH new_cart_item AS ( 15 | INSERT INTO cart_items (cart_id, product_id, quantity) 16 | VALUES ($1, $2, $3) 17 | RETURNING * 18 | ) 19 | SELECT 20 | new_cart_item.*, 21 | products.name, 22 | products.price * new_cart_item.quantity AS "total_price", 23 | products.description, 24 | products.quantity > 0 AS "in_stock" 25 | FROM new_cart_item 26 | JOIN products 27 | ON new_cart_item.product_id = products.id`; 28 | 29 | // pg values 30 | const values = [data.cart_id, data.product_id, data.quantity] 31 | 32 | // make query 33 | const result = await db.query(statement, values); 34 | 35 | // check for valid results 36 | if (result.rows.length > 0) { 37 | return result.rows[0]; 38 | } else { 39 | return null; 40 | } 41 | } catch(err) { 42 | throw new Error(err); 43 | } 44 | } 45 | 46 | /** 47 | * Updates cart item in database , if exists 48 | * 49 | * @param {Object} data 50 | * @return {Object|null} the cart item 51 | */ 52 | async update(data) { 53 | try { 54 | // pg statement 55 | const statement = `WITH updated AS ( 56 | UPDATE cart_items 57 | SET quantity=$3, modified=now() 58 | WHERE cart_id=$1 AND product_id=$2 59 | RETURNING * 60 | ) 61 | SELECT 62 | updated.*, 63 | products.name, 64 | products.price * updated.quantity AS "total_price", 65 | products.description, 66 | products.quantity > 0 AS "in_stock" 67 | FROM updated 68 | JOIN products 69 | ON updated.product_id = products.id`; 70 | 71 | // pg values 72 | const values = [data.cart_id, data.product_id, data.quantity]; 73 | 74 | // make query 75 | const result = await db.query(statement, values); 76 | 77 | // check for valid results 78 | if (result.rows.length > 0) { 79 | return result.rows[0]; 80 | } else { 81 | return null; 82 | } 83 | } catch(err) { 84 | throw new Error(err); 85 | } 86 | } 87 | 88 | /** 89 | * Returns cart items associated with cart_id in database, if exists 90 | * 91 | * @param {number} cart_id the cart_id to find cart items based on 92 | * @return {Array|null} the cart items 93 | */ 94 | async findInCart(cart_id) { 95 | try { 96 | // pg statement 97 | const statement = `WITH cart AS ( 98 | SELECT * 99 | FROM cart_items 100 | WHERE cart_id = $1 101 | ) 102 | SELECT 103 | cart.*, 104 | products.name, 105 | products.price * cart.quantity AS "total_price", 106 | products.description, 107 | products.quantity > 0 AS "in_stock" 108 | FROM cart 109 | JOIN products 110 | ON cart.product_id = products.id`; 111 | 112 | // pg values 113 | const values = [cart_id]; 114 | 115 | // make query 116 | const result = await db.query(statement, values); 117 | 118 | // check for valid results 119 | if (result.rows.length > 0) { 120 | return result.rows; 121 | } else { 122 | return null; 123 | } 124 | } catch(err) { 125 | throw new Error(err); 126 | } 127 | } 128 | 129 | /** 130 | * Returns cart item from database based on cart_id and product_id, if exists 131 | * 132 | * @param {Object} data 133 | * @return {Object|null} the cart item 134 | */ 135 | async findOne(data) { 136 | try { 137 | // pg statement 138 | const statement = `SELECT 139 | cart_items.*, 140 | products.name, 141 | products.price * cart_items.quantity AS "total_price", 142 | products.description, 143 | products.quantity > 0 AS "in_stock" 144 | FROM cart_items 145 | JOIN products 146 | ON cart_items.product_id = products.id 147 | WHERE cart_id = $1 148 | AND product_id = $2`; 149 | 150 | // pg values 151 | const values = [data.cart_id, data.product_id]; 152 | 153 | // make query 154 | const result = await db.query(statement, values); 155 | 156 | // check for valid results 157 | if (result.rows.length > 0) { 158 | return result.rows[0]; 159 | } else { 160 | return null; 161 | } 162 | } catch(err) { 163 | throw new Error(err); 164 | } 165 | } 166 | 167 | /** 168 | * Deletes cart item from database , if exists 169 | * 170 | * @param {Object} data 171 | * @return {Object|null} the cart item 172 | */ 173 | async delete(data) { 174 | try { 175 | // pg statement 176 | const statement = `WITH deleted_item AS ( 177 | DELETE FROM cart_items 178 | WHERE cart_id=$1 AND product_id=$2 179 | RETURNING * 180 | ) 181 | SELECT 182 | deleted_item.cart_id, 183 | deleted_item.product_id, 184 | deleted_item.created, 185 | deleted_item.modified, 186 | deleted_item.quantity * 0 AS "quantity", 187 | products.name, 188 | products.price * 0 AS "total_price", 189 | products.description, 190 | products.quantity > 0 AS "in_stock" 191 | FROM deleted_item 192 | JOIN products 193 | ON deleted_item.product_id = products.id`; 194 | 195 | // pg values 196 | const values = [data.cart_id, data.product_id]; 197 | 198 | // make query 199 | const result = await db.query(statement, values); 200 | 201 | // check for valid results 202 | if (result.rows.length > 0) { 203 | return result.rows[0]; 204 | } else { 205 | return null; 206 | } 207 | } catch(err) { 208 | throw new Error(err); 209 | } 210 | } 211 | } 212 | 213 | module.exports = new CartItem(); --------------------------------------------------------------------------------