├── .gitignore ├── src ├── version.json ├── model │ └── model.js ├── index.js ├── endpointHelper.js ├── init.js ├── service.js ├── database │ ├── dbModel.js │ └── database.js └── routes │ ├── userRouter.js │ ├── authRouter.js │ ├── orderRouter.js │ └── franchiseRouter.js ├── package.json ├── LICENSE ├── deployService.sh └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/.DS_Store 3 | .vscode 4 | config.js 5 | coverage 6 | dist -------------------------------------------------------------------------------- /src/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "20240518.154317", 3 | "def": { 4 | "franchiseId": 1, 5 | "name": "SLC" 6 | } 7 | } -------------------------------------------------------------------------------- /src/model/model.js: -------------------------------------------------------------------------------- 1 | const Role = { 2 | Diner: 'diner', 3 | Franchisee: 'franchisee', 4 | Admin: 'admin', 5 | }; 6 | 7 | module.exports = { Role }; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const app = require('./service.js'); 2 | 3 | const port = process.argv[2] || 3000; 4 | app.listen(port, () => { 5 | console.log(`Server started on port ${port}`); 6 | }); 7 | -------------------------------------------------------------------------------- /src/endpointHelper.js: -------------------------------------------------------------------------------- 1 | class StatusCodeError extends Error { 2 | constructor(message, statusCode) { 3 | super(message); 4 | this.statusCode = statusCode; 5 | } 6 | } 7 | 8 | const asyncHandler = (fn) => (req, res, next) => { 9 | return Promise.resolve(fn(req, res, next)).catch(next); 10 | }; 11 | 12 | module.exports = { 13 | asyncHandler, 14 | StatusCodeError, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jwt-pizza-service", 3 | "description": "Backend service for making JWT pizzas", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "cd src && node index.js" 8 | }, 9 | "license": "MIT", 10 | "dependencies": { 11 | "bcrypt": "^5.1.1", 12 | "express": "^4.19.2", 13 | "jsonwebtoken": "^9.0.2", 14 | "mysql2": "^3.9.7" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | const { Role, DB } = require('./database/database.js'); 2 | 3 | if (process.argv.length < 5) { 4 | console.log('Usage: node init.js '); 5 | process.exit(1); 6 | } 7 | 8 | const name = process.argv[2]; 9 | const email = process.argv[3]; 10 | const password = process.argv[4]; 11 | const user = { name, email, password, roles: [{ role: Role.Admin }] }; 12 | DB.addUser(user).then((r) => console.log('created user: ', r)); 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 BYU CS DevOps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /deployService.sh: -------------------------------------------------------------------------------- 1 | while getopts k:h:s: flag 2 | do 3 | case "${flag}" in 4 | k) key=${OPTARG};; 5 | h) hostname=${OPTARG};; 6 | esac 7 | done 8 | 9 | if [[ -z "$key" || -z "$hostname" ]]; then 10 | printf "\nMissing required parameter.\n" 11 | printf " syntax: deployService.sh -k -h \n\n" 12 | exit 1 13 | fi 14 | service="jwt-pizza-service" 15 | 16 | printf "\n----> Deploying $service to $hostname with $key\n" 17 | 18 | # Step 1 19 | printf "\n----> Build the distribution package\n" 20 | rm -rf dist 21 | mkdir dist 22 | cp -r src/* dist 23 | cp *.json dist 24 | 25 | # Step 2 26 | printf "\n----> Clearing out previous distribution on the target\n" 27 | ssh -i "$key" ubuntu@$hostname << ENDSSH 28 | rm -rf services/${service} 29 | mkdir -p services/${service} 30 | ENDSSH 31 | 32 | # Step 3 33 | printf "\n----> Copy the distribution package to the target\n" 34 | scp -r -i "$key" dist/* ubuntu@$hostname:services/$service 35 | 36 | # Step 4 37 | printf "\n----> Deploy the service on the target\n" 38 | ssh -i "$key" ubuntu@$hostname << ENDSSH 39 | bash -i 40 | cd services/${service} 41 | npm install 42 | pm2 restart ${service} 43 | ENDSSH 44 | 45 | # Step 5 46 | printf "\n----> Removing local copy of the distribution package\n" 47 | rm -rf dist 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍕 jwt-pizza-service 2 | 3 | ![Coverage badge](https://pizza-factory.cs329.click/api/badge/accountId/jwtpizzaservicecoverage) 4 | 5 | Backend service for making JWT pizzas. This service tracks users and franchises and orders pizzas. All order requests are passed to the JWT Pizza Factory where the pizzas are made. 6 | 7 | JWTs are used for authentication objects. 8 | 9 | ## Deployment 10 | 11 | In order for the server to work correctly it must be configured by providing a `config.js` file. 12 | 13 | ```js 14 | module.exports = { 15 | // Your JWT secret can be any random string you would like. It just needs to be secret. 16 | jwtSecret: 'yourjwtsecrethere', 17 | db: { 18 | connection: { 19 | host: '127.0.0.1', 20 | user: 'root', 21 | password: 'yourpasswordhere', 22 | database: 'pizza', 23 | connectTimeout: 60000, 24 | }, 25 | listPerPage: 10, 26 | }, 27 | factory: { 28 | url: 'https://pizza-factory.cs329.click', 29 | apiKey: 'yourapikeyhere', 30 | }, 31 | }; 32 | ``` 33 | 34 | ## Endpoints 35 | 36 | You can get the documentation for all endpoints by making the following request. 37 | 38 | ```sh 39 | curl localhost:3000/api/docs 40 | ``` 41 | 42 | ## Development notes 43 | 44 | Install the required packages. 45 | 46 | ```sh 47 | npm install express jsonwebtoken mysql2 bcrypt 48 | ``` 49 | 50 | Nodemon is assumed to be installed globally so that you can have hot reloading when debugging. 51 | 52 | ```sh 53 | npm -g install nodemon 54 | ``` 55 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { authRouter, setAuthUser } = require('./routes/authRouter.js'); 3 | const orderRouter = require('./routes/orderRouter.js'); 4 | const franchiseRouter = require('./routes/franchiseRouter.js'); 5 | const userRouter = require('./routes/userRouter.js'); 6 | const version = require('./version.json'); 7 | const config = require('./config.js'); 8 | 9 | const app = express(); 10 | app.use(express.json()); 11 | app.use(setAuthUser); 12 | app.use((req, res, next) => { 13 | res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); 14 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); 15 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 16 | res.setHeader('Access-Control-Allow-Credentials', 'true'); 17 | next(); 18 | }); 19 | 20 | const apiRouter = express.Router(); 21 | app.use('/api', apiRouter); 22 | apiRouter.use('/auth', authRouter); 23 | apiRouter.use('/user', userRouter); 24 | apiRouter.use('/order', orderRouter); 25 | apiRouter.use('/franchise', franchiseRouter); 26 | 27 | apiRouter.use('/docs', (req, res) => { 28 | res.json({ 29 | version: version.version, 30 | endpoints: [...authRouter.docs, ...userRouter.docs, ...orderRouter.docs, ...franchiseRouter.docs], 31 | config: { factory: config.factory.url, db: config.db.connection.host }, 32 | }); 33 | }); 34 | 35 | app.get('/', (req, res) => { 36 | res.json({ 37 | message: 'welcome to JWT Pizza', 38 | version: version.version, 39 | }); 40 | }); 41 | 42 | app.use('*', (req, res) => { 43 | res.status(404).json({ 44 | message: 'unknown endpoint', 45 | }); 46 | }); 47 | 48 | // Default error handler for all exceptions and errors. 49 | app.use((err, req, res, next) => { 50 | res.status(err.statusCode ?? 500).json({ message: err.message, stack: err.stack }); 51 | next(); 52 | }); 53 | 54 | module.exports = app; 55 | -------------------------------------------------------------------------------- /src/database/dbModel.js: -------------------------------------------------------------------------------- 1 | const tableCreateStatements = [ 2 | `CREATE TABLE IF NOT EXISTS auth ( 3 | token VARCHAR(512) PRIMARY KEY, 4 | userId INT NOT NULL 5 | )`, 6 | 7 | `CREATE TABLE IF NOT EXISTS user ( 8 | id INT AUTO_INCREMENT PRIMARY KEY, 9 | name VARCHAR(255) NOT NULL, 10 | email VARCHAR(255) NOT NULL, 11 | password VARCHAR(255) NOT NULL 12 | )`, 13 | 14 | `CREATE TABLE IF NOT EXISTS menu ( 15 | id INT AUTO_INCREMENT PRIMARY KEY, 16 | title VARCHAR(255) NOT NULL, 17 | image VARCHAR(1024) NOT NULL, 18 | price DECIMAL(10, 8) NOT NULL, 19 | description TEXT NOT NULL 20 | )`, 21 | 22 | `CREATE TABLE IF NOT EXISTS franchise ( 23 | id INT AUTO_INCREMENT PRIMARY KEY, 24 | name VARCHAR(255) NOT NULL UNIQUE 25 | )`, 26 | 27 | `CREATE TABLE IF NOT EXISTS store ( 28 | id INT AUTO_INCREMENT PRIMARY KEY, 29 | franchiseId INT NOT NULL, 30 | name VARCHAR(255) NOT NULL, 31 | FOREIGN KEY (franchiseId) REFERENCES franchise(id) 32 | )`, 33 | 34 | `CREATE TABLE IF NOT EXISTS userRole ( 35 | id INT AUTO_INCREMENT PRIMARY KEY, 36 | userId INT NOT NULL, 37 | role VARCHAR(255) NOT NULL, 38 | objectId INT NOT NULL, 39 | FOREIGN KEY (userId) REFERENCES user(id), 40 | INDEX (objectId) 41 | )`, 42 | 43 | `CREATE TABLE IF NOT EXISTS dinerOrder ( 44 | id INT AUTO_INCREMENT PRIMARY KEY, 45 | dinerId INT NOT NULL, 46 | franchiseId INT NOT NULL, 47 | storeId INT NOT NULL, 48 | date DATETIME NOT NULL, 49 | INDEX (dinerId), 50 | INDEX (franchiseId), 51 | INDEX (storeId) 52 | )`, 53 | 54 | `CREATE TABLE IF NOT EXISTS orderItem ( 55 | id INT AUTO_INCREMENT PRIMARY KEY, 56 | orderId INT NOT NULL, 57 | menuId INT NOT NULL, 58 | description VARCHAR(255) NOT NULL, 59 | price DECIMAL(10, 8) NOT NULL, 60 | FOREIGN KEY (orderId) REFERENCES dinerOrder(id), 61 | INDEX (menuId) 62 | )`, 63 | ]; 64 | 65 | module.exports = { tableCreateStatements }; 66 | -------------------------------------------------------------------------------- /src/routes/userRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { asyncHandler } = require('../endpointHelper.js'); 3 | const { DB, Role } = require('../database/database.js'); 4 | const { authRouter, setAuth } = require('./authRouter.js'); 5 | 6 | const userRouter = express.Router(); 7 | 8 | userRouter.docs = [ 9 | { 10 | method: 'GET', 11 | path: '/api/user/me', 12 | requiresAuth: true, 13 | description: 'Get authenticated user', 14 | example: `curl -X GET localhost:3000/api/user/me -H 'Authorization: Bearer tttttt'`, 15 | response: { id: 1, name: '常用名字', email: 'a@jwt.com', roles: [{ role: 'admin' }] }, 16 | }, 17 | { 18 | method: 'PUT', 19 | path: '/api/user/:userId', 20 | requiresAuth: true, 21 | description: 'Update user', 22 | example: `curl -X PUT localhost:3000/api/user/1 -d '{"name":"常用名字", "email":"a@jwt.com", "password":"admin"}' -H 'Content-Type: application/json' -H 'Authorization: Bearer tttttt'`, 23 | response: { user: { id: 1, name: '常用名字', email: 'a@jwt.com', roles: [{ role: 'admin' }] }, token: 'tttttt' }, 24 | }, 25 | ]; 26 | 27 | // getUser 28 | userRouter.get( 29 | '/me', 30 | authRouter.authenticateToken, 31 | asyncHandler(async (req, res) => { 32 | res.json(req.user); 33 | }) 34 | ); 35 | 36 | // updateUser 37 | userRouter.put( 38 | '/:userId', 39 | authRouter.authenticateToken, 40 | asyncHandler(async (req, res) => { 41 | const { name, email, password } = req.body; 42 | const userId = Number(req.params.userId); 43 | const user = req.user; 44 | if (user.id !== userId && !user.isRole(Role.Admin)) { 45 | return res.status(403).json({ message: 'unauthorized' }); 46 | } 47 | 48 | const updatedUser = await DB.updateUser(userId, name, email, password); 49 | const auth = await setAuth(updatedUser); 50 | res.json({ user: updatedUser, token: auth }); 51 | }) 52 | ); 53 | 54 | // deleteUser 55 | userRouter.delete( 56 | '/:userId', 57 | authRouter.authenticateToken, 58 | asyncHandler(async (req, res) => { 59 | res.json({ message: 'not implemented' }); 60 | }) 61 | ); 62 | 63 | // listUsers 64 | userRouter.get( 65 | '/', 66 | authRouter.authenticateToken, 67 | asyncHandler(async (req, res) => { 68 | res.json({ message: 'not implemented', users: [], more: false }); 69 | }) 70 | ); 71 | 72 | module.exports = userRouter; 73 | -------------------------------------------------------------------------------- /src/routes/authRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const jwt = require('jsonwebtoken'); 3 | const config = require('../config.js'); 4 | const { asyncHandler } = require('../endpointHelper.js'); 5 | const { DB, Role } = require('../database/database.js'); 6 | 7 | const authRouter = express.Router(); 8 | 9 | authRouter.docs = [ 10 | { 11 | method: 'POST', 12 | path: '/api/auth', 13 | description: 'Register a new user', 14 | example: `curl -X POST localhost:3000/api/auth -d '{"name":"pizza diner", "email":"d@jwt.com", "password":"diner"}' -H 'Content-Type: application/json'`, 15 | response: { user: { id: 2, name: 'pizza diner', email: 'd@jwt.com', roles: [{ role: 'diner' }] }, token: 'tttttt' }, 16 | }, 17 | { 18 | method: 'PUT', 19 | path: '/api/auth', 20 | description: 'Login existing user', 21 | example: `curl -X PUT localhost:3000/api/auth -d '{"email":"a@jwt.com", "password":"admin"}' -H 'Content-Type: application/json'`, 22 | response: { user: { id: 1, name: '常用名字', email: 'a@jwt.com', roles: [{ role: 'admin' }] }, token: 'tttttt' }, 23 | }, 24 | { 25 | method: 'DELETE', 26 | path: '/api/auth', 27 | requiresAuth: true, 28 | description: 'Logout a user', 29 | example: `curl -X DELETE localhost:3000/api/auth -H 'Authorization: Bearer tttttt'`, 30 | response: { message: 'logout successful' }, 31 | }, 32 | ]; 33 | 34 | async function setAuthUser(req, res, next) { 35 | const token = readAuthToken(req); 36 | if (token) { 37 | try { 38 | if (await DB.isLoggedIn(token)) { 39 | // Check the database to make sure the token is valid. 40 | req.user = jwt.verify(token, config.jwtSecret); 41 | req.user.isRole = (role) => !!req.user.roles.find((r) => r.role === role); 42 | } 43 | } catch { 44 | req.user = null; 45 | } 46 | } 47 | next(); 48 | } 49 | 50 | // Authenticate token 51 | authRouter.authenticateToken = (req, res, next) => { 52 | if (!req.user) { 53 | return res.status(401).send({ message: 'unauthorized' }); 54 | } 55 | next(); 56 | }; 57 | 58 | // register 59 | authRouter.post( 60 | '/', 61 | asyncHandler(async (req, res) => { 62 | const { name, email, password } = req.body; 63 | if (!name || !email || !password) { 64 | return res.status(400).json({ message: 'name, email, and password are required' }); 65 | } 66 | const user = await DB.addUser({ name, email, password, roles: [{ role: Role.Diner }] }); 67 | const auth = await setAuth(user); 68 | res.json({ user: user, token: auth }); 69 | }) 70 | ); 71 | 72 | // login 73 | authRouter.put( 74 | '/', 75 | asyncHandler(async (req, res) => { 76 | const { email, password } = req.body; 77 | const user = await DB.getUser(email, password); 78 | const auth = await setAuth(user); 79 | res.json({ user: user, token: auth }); 80 | }) 81 | ); 82 | 83 | // logout 84 | authRouter.delete( 85 | '/', 86 | authRouter.authenticateToken, 87 | asyncHandler(async (req, res) => { 88 | await clearAuth(req); 89 | res.json({ message: 'logout successful' }); 90 | }) 91 | ); 92 | 93 | async function setAuth(user) { 94 | const token = jwt.sign(user, config.jwtSecret); 95 | await DB.loginUser(user.id, token); 96 | return token; 97 | } 98 | 99 | async function clearAuth(req) { 100 | const token = readAuthToken(req); 101 | if (token) { 102 | await DB.logoutUser(token); 103 | } 104 | } 105 | 106 | function readAuthToken(req) { 107 | const authHeader = req.headers.authorization; 108 | if (authHeader) { 109 | return authHeader.split(' ')[1]; 110 | } 111 | return null; 112 | } 113 | 114 | module.exports = { authRouter, setAuthUser, setAuth }; 115 | -------------------------------------------------------------------------------- /src/routes/orderRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const config = require('../config.js'); 3 | const { Role, DB } = require('../database/database.js'); 4 | const { authRouter } = require('./authRouter.js'); 5 | const { asyncHandler, StatusCodeError } = require('../endpointHelper.js'); 6 | 7 | const orderRouter = express.Router(); 8 | 9 | orderRouter.docs = [ 10 | { 11 | method: 'GET', 12 | path: '/api/order/menu', 13 | description: 'Get the pizza menu', 14 | example: `curl localhost:3000/api/order/menu`, 15 | response: [{ id: 1, title: 'Veggie', image: 'pizza1.png', price: 0.0038, description: 'A garden of delight' }], 16 | }, 17 | { 18 | method: 'PUT', 19 | path: '/api/order/menu', 20 | requiresAuth: true, 21 | description: 'Add an item to the menu', 22 | example: `curl -X PUT localhost:3000/api/order/menu -H 'Content-Type: application/json' -d '{ "title":"Student", "description": "No topping, no sauce, just carbs", "image":"pizza9.png", "price": 0.0001 }' -H 'Authorization: Bearer tttttt'`, 23 | response: [{ id: 1, title: 'Student', description: 'No topping, no sauce, just carbs', image: 'pizza9.png', price: 0.0001 }], 24 | }, 25 | { 26 | method: 'GET', 27 | path: '/api/order', 28 | requiresAuth: true, 29 | description: 'Get the orders for the authenticated user', 30 | example: `curl -X GET localhost:3000/api/order -H 'Authorization: Bearer tttttt'`, 31 | response: { dinerId: 4, orders: [{ id: 1, franchiseId: 1, storeId: 1, date: '2024-06-05T05:14:40.000Z', items: [{ id: 1, menuId: 1, description: 'Veggie', price: 0.05 }] }], page: 1 }, 32 | }, 33 | { 34 | method: 'POST', 35 | path: '/api/order', 36 | requiresAuth: true, 37 | description: 'Create a order for the authenticated user', 38 | example: `curl -X POST localhost:3000/api/order -H 'Content-Type: application/json' -d '{"franchiseId": 1, "storeId":1, "items":[{ "menuId": 1, "description": "Veggie", "price": 0.05 }]}' -H 'Authorization: Bearer tttttt'`, 39 | response: { order: { franchiseId: 1, storeId: 1, items: [{ menuId: 1, description: 'Veggie', price: 0.05 }], id: 1 }, jwt: '1111111111' }, 40 | }, 41 | ]; 42 | 43 | // getMenu 44 | orderRouter.get( 45 | '/menu', 46 | asyncHandler(async (req, res) => { 47 | res.send(await DB.getMenu()); 48 | }) 49 | ); 50 | 51 | // addMenuItem 52 | orderRouter.put( 53 | '/menu', 54 | authRouter.authenticateToken, 55 | asyncHandler(async (req, res) => { 56 | if (!req.user.isRole(Role.Admin)) { 57 | throw new StatusCodeError('unable to add menu item', 403); 58 | } 59 | 60 | const addMenuItemReq = req.body; 61 | await DB.addMenuItem(addMenuItemReq); 62 | res.send(await DB.getMenu()); 63 | }) 64 | ); 65 | 66 | // getOrders 67 | orderRouter.get( 68 | '/', 69 | authRouter.authenticateToken, 70 | asyncHandler(async (req, res) => { 71 | res.json(await DB.getOrders(req.user, req.query.page)); 72 | }) 73 | ); 74 | 75 | // createOrder 76 | orderRouter.post( 77 | '/', 78 | authRouter.authenticateToken, 79 | asyncHandler(async (req, res) => { 80 | const orderReq = req.body; 81 | const order = await DB.addDinerOrder(req.user, orderReq); 82 | const r = await fetch(`${config.factory.url}/api/order`, { 83 | method: 'POST', 84 | headers: { 'Content-Type': 'application/json', authorization: `Bearer ${config.factory.apiKey}` }, 85 | body: JSON.stringify({ diner: { id: req.user.id, name: req.user.name, email: req.user.email }, order }), 86 | }); 87 | const j = await r.json(); 88 | if (r.ok) { 89 | res.send({ order, followLinkToEndChaos: j.reportUrl, jwt: j.jwt }); 90 | } else { 91 | res.status(500).send({ message: 'Failed to fulfill order at factory', followLinkToEndChaos: j.reportUrl }); 92 | } 93 | }) 94 | ); 95 | 96 | module.exports = orderRouter; 97 | -------------------------------------------------------------------------------- /src/routes/franchiseRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { DB, Role } = require('../database/database.js'); 3 | const { authRouter } = require('./authRouter.js'); 4 | const { StatusCodeError, asyncHandler } = require('../endpointHelper.js'); 5 | 6 | const franchiseRouter = express.Router(); 7 | 8 | franchiseRouter.docs = [ 9 | { 10 | method: 'GET', 11 | path: '/api/franchise?page=0&limit=10&name=*', 12 | description: 'List all the franchises', 13 | example: `curl localhost:3000/api/franchise&page=0&limit=10&name=pizzaPocket`, 14 | response: { franchises: [{ id: 1, name: 'pizzaPocket', admins: [{ id: 4, name: 'pizza franchisee', email: 'f@jwt.com' }], stores: [{ id: 1, name: 'SLC', totalRevenue: 0 }] }], more: true }, 15 | }, 16 | { 17 | method: 'GET', 18 | path: '/api/franchise/:userId', 19 | requiresAuth: true, 20 | description: `List a user's franchises`, 21 | example: `curl localhost:3000/api/franchise/4 -H 'Authorization: Bearer tttttt'`, 22 | response: [{ id: 2, name: 'pizzaPocket', admins: [{ id: 4, name: 'pizza franchisee', email: 'f@jwt.com' }], stores: [{ id: 4, name: 'SLC', totalRevenue: 0 }] }], 23 | }, 24 | { 25 | method: 'POST', 26 | path: '/api/franchise', 27 | requiresAuth: true, 28 | description: 'Create a new franchise', 29 | example: `curl -X POST localhost:3000/api/franchise -H 'Content-Type: application/json' -H 'Authorization: Bearer tttttt' -d '{"name": "pizzaPocket", "admins": [{"email": "f@jwt.com"}]}'`, 30 | response: { name: 'pizzaPocket', admins: [{ email: 'f@jwt.com', id: 4, name: 'pizza franchisee' }], id: 1 }, 31 | }, 32 | { 33 | method: 'DELETE', 34 | path: '/api/franchise/:franchiseId', 35 | requiresAuth: true, 36 | description: `Delete a franchises`, 37 | example: `curl -X DELETE localhost:3000/api/franchise/1 -H 'Authorization: Bearer tttttt'`, 38 | response: { message: 'franchise deleted' }, 39 | }, 40 | { 41 | method: 'POST', 42 | path: '/api/franchise/:franchiseId/store', 43 | requiresAuth: true, 44 | description: 'Create a new franchise store', 45 | example: `curl -X POST localhost:3000/api/franchise/1/store -H 'Content-Type: application/json' -d '{"franchiseId": 1, "name":"SLC"}' -H 'Authorization: Bearer tttttt'`, 46 | response: { id: 1, name: 'SLC', totalRevenue: 0 }, 47 | }, 48 | { 49 | method: 'DELETE', 50 | path: '/api/franchise/:franchiseId/store/:storeId', 51 | requiresAuth: true, 52 | description: `Delete a store`, 53 | example: `curl -X DELETE localhost:3000/api/franchise/1/store/1 -H 'Authorization: Bearer tttttt'`, 54 | response: { message: 'store deleted' }, 55 | }, 56 | ]; 57 | 58 | // getFranchises 59 | franchiseRouter.get( 60 | '/', 61 | asyncHandler(async (req, res) => { 62 | const [franchises, more] = await DB.getFranchises(req.user, req.query.page, req.query.limit, req.query.name); 63 | res.json({ franchises, more }); 64 | }) 65 | ); 66 | 67 | // getUserFranchises 68 | franchiseRouter.get( 69 | '/:userId', 70 | authRouter.authenticateToken, 71 | asyncHandler(async (req, res) => { 72 | let result = []; 73 | const userId = Number(req.params.userId); 74 | if (req.user.id === userId || req.user.isRole(Role.Admin)) { 75 | result = await DB.getUserFranchises(userId); 76 | } 77 | 78 | res.json(result); 79 | }) 80 | ); 81 | 82 | // createFranchise 83 | franchiseRouter.post( 84 | '/', 85 | authRouter.authenticateToken, 86 | asyncHandler(async (req, res) => { 87 | if (!req.user.isRole(Role.Admin)) { 88 | throw new StatusCodeError('unable to create a franchise', 403); 89 | } 90 | 91 | const franchise = req.body; 92 | res.send(await DB.createFranchise(franchise)); 93 | }) 94 | ); 95 | 96 | // deleteFranchise 97 | franchiseRouter.delete( 98 | '/:franchiseId', 99 | asyncHandler(async (req, res) => { 100 | const franchiseId = Number(req.params.franchiseId); 101 | await DB.deleteFranchise(franchiseId); 102 | res.json({ message: 'franchise deleted' }); 103 | }) 104 | ); 105 | 106 | // createStore 107 | franchiseRouter.post( 108 | '/:franchiseId/store', 109 | authRouter.authenticateToken, 110 | asyncHandler(async (req, res) => { 111 | const franchiseId = Number(req.params.franchiseId); 112 | const franchise = await DB.getFranchise({ id: franchiseId }); 113 | if (!franchise || (!req.user.isRole(Role.Admin) && !franchise.admins.some((admin) => admin.id === req.user.id))) { 114 | throw new StatusCodeError('unable to create a store', 403); 115 | } 116 | 117 | res.send(await DB.createStore(franchise.id, req.body)); 118 | }) 119 | ); 120 | 121 | // deleteStore 122 | franchiseRouter.delete( 123 | '/:franchiseId/store/:storeId', 124 | authRouter.authenticateToken, 125 | asyncHandler(async (req, res) => { 126 | const franchiseId = Number(req.params.franchiseId); 127 | const franchise = await DB.getFranchise({ id: franchiseId }); 128 | if (!franchise || (!req.user.isRole(Role.Admin) && !franchise.admins.some((admin) => admin.id === req.user.id))) { 129 | throw new StatusCodeError('unable to delete a store', 403); 130 | } 131 | 132 | const storeId = Number(req.params.storeId); 133 | await DB.deleteStore(franchiseId, storeId); 134 | res.json({ message: 'store deleted' }); 135 | }) 136 | ); 137 | 138 | module.exports = franchiseRouter; 139 | -------------------------------------------------------------------------------- /src/database/database.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql2/promise'); 2 | const bcrypt = require('bcrypt'); 3 | const config = require('../config.js'); 4 | const { StatusCodeError } = require('../endpointHelper.js'); 5 | const { Role } = require('../model/model.js'); 6 | const dbModel = require('./dbModel.js'); 7 | class DB { 8 | constructor() { 9 | this.initialized = this.initializeDatabase(); 10 | } 11 | 12 | async getMenu() { 13 | const connection = await this.getConnection(); 14 | try { 15 | const rows = await this.query(connection, `SELECT * FROM menu`); 16 | return rows; 17 | } finally { 18 | connection.end(); 19 | } 20 | } 21 | 22 | async addMenuItem(item) { 23 | const connection = await this.getConnection(); 24 | try { 25 | const addResult = await this.query(connection, `INSERT INTO menu (title, description, image, price) VALUES (?, ?, ?, ?)`, [item.title, item.description, item.image, item.price]); 26 | return { ...item, id: addResult.insertId }; 27 | } finally { 28 | connection.end(); 29 | } 30 | } 31 | 32 | async addUser(user) { 33 | const connection = await this.getConnection(); 34 | try { 35 | const hashedPassword = await bcrypt.hash(user.password, 10); 36 | 37 | const userResult = await this.query(connection, `INSERT INTO user (name, email, password) VALUES (?, ?, ?)`, [user.name, user.email, hashedPassword]); 38 | const userId = userResult.insertId; 39 | for (const role of user.roles) { 40 | switch (role.role) { 41 | case Role.Franchisee: { 42 | const franchiseId = await this.getID(connection, 'name', role.object, 'franchise'); 43 | await this.query(connection, `INSERT INTO userRole (userId, role, objectId) VALUES (?, ?, ?)`, [userId, role.role, franchiseId]); 44 | break; 45 | } 46 | default: { 47 | await this.query(connection, `INSERT INTO userRole (userId, role, objectId) VALUES (?, ?, ?)`, [userId, role.role, 0]); 48 | break; 49 | } 50 | } 51 | } 52 | return { ...user, id: userId, password: undefined }; 53 | } finally { 54 | connection.end(); 55 | } 56 | } 57 | 58 | async getUser(email, password) { 59 | const connection = await this.getConnection(); 60 | try { 61 | const userResult = await this.query(connection, `SELECT * FROM user WHERE email=?`, [email]); 62 | const user = userResult[0]; 63 | if (!user || (password && !(await bcrypt.compare(password, user.password)))) { 64 | throw new StatusCodeError('unknown user', 404); 65 | } 66 | 67 | const roleResult = await this.query(connection, `SELECT * FROM userRole WHERE userId=?`, [user.id]); 68 | const roles = roleResult.map((r) => { 69 | return { objectId: r.objectId || undefined, role: r.role }; 70 | }); 71 | 72 | return { ...user, roles: roles, password: undefined }; 73 | } finally { 74 | connection.end(); 75 | } 76 | } 77 | 78 | async updateUser(userId, name, email, password) { 79 | const connection = await this.getConnection(); 80 | try { 81 | const params = []; 82 | if (password) { 83 | const hashedPassword = await bcrypt.hash(password, 10); 84 | params.push(`password='${hashedPassword}'`); 85 | } 86 | if (email) { 87 | params.push(`email='${email}'`); 88 | } 89 | if (name) { 90 | params.push(`name='${name}'`); 91 | } 92 | if (params.length > 0) { 93 | const query = `UPDATE user SET ${params.join(', ')} WHERE id=${userId}`; 94 | await this.query(connection, query); 95 | } 96 | return this.getUser(email, password); 97 | } finally { 98 | connection.end(); 99 | } 100 | } 101 | 102 | async loginUser(userId, token) { 103 | token = this.getTokenSignature(token); 104 | const connection = await this.getConnection(); 105 | try { 106 | await this.query(connection, `INSERT INTO auth (token, userId) VALUES (?, ?) ON DUPLICATE KEY UPDATE token=token`, [token, userId]); 107 | } finally { 108 | connection.end(); 109 | } 110 | } 111 | 112 | async isLoggedIn(token) { 113 | token = this.getTokenSignature(token); 114 | const connection = await this.getConnection(); 115 | try { 116 | const authResult = await this.query(connection, `SELECT userId FROM auth WHERE token=?`, [token]); 117 | return authResult.length > 0; 118 | } finally { 119 | connection.end(); 120 | } 121 | } 122 | 123 | async logoutUser(token) { 124 | token = this.getTokenSignature(token); 125 | const connection = await this.getConnection(); 126 | try { 127 | await this.query(connection, `DELETE FROM auth WHERE token=?`, [token]); 128 | } finally { 129 | connection.end(); 130 | } 131 | } 132 | 133 | async getOrders(user, page = 1) { 134 | const connection = await this.getConnection(); 135 | try { 136 | const offset = this.getOffset(page, config.db.listPerPage); 137 | const orders = await this.query(connection, `SELECT id, franchiseId, storeId, date FROM dinerOrder WHERE dinerId=? LIMIT ${offset},${config.db.listPerPage}`, [user.id]); 138 | for (const order of orders) { 139 | let items = await this.query(connection, `SELECT id, menuId, description, price FROM orderItem WHERE orderId=?`, [order.id]); 140 | order.items = items; 141 | } 142 | return { dinerId: user.id, orders: orders, page }; 143 | } finally { 144 | connection.end(); 145 | } 146 | } 147 | 148 | async addDinerOrder(user, order) { 149 | const connection = await this.getConnection(); 150 | try { 151 | const orderResult = await this.query(connection, `INSERT INTO dinerOrder (dinerId, franchiseId, storeId, date) VALUES (?, ?, ?, now())`, [user.id, order.franchiseId, order.storeId]); 152 | const orderId = orderResult.insertId; 153 | for (const item of order.items) { 154 | const menuId = await this.getID(connection, 'id', item.menuId, 'menu'); 155 | await this.query(connection, `INSERT INTO orderItem (orderId, menuId, description, price) VALUES (?, ?, ?, ?)`, [orderId, menuId, item.description, item.price]); 156 | } 157 | return { ...order, id: orderId }; 158 | } finally { 159 | connection.end(); 160 | } 161 | } 162 | 163 | async createFranchise(franchise) { 164 | const connection = await this.getConnection(); 165 | try { 166 | for (const admin of franchise.admins) { 167 | const adminUser = await this.query(connection, `SELECT id, name FROM user WHERE email=?`, [admin.email]); 168 | if (adminUser.length == 0) { 169 | throw new StatusCodeError(`unknown user for franchise admin ${admin.email} provided`, 404); 170 | } 171 | admin.id = adminUser[0].id; 172 | admin.name = adminUser[0].name; 173 | } 174 | 175 | const franchiseResult = await this.query(connection, `INSERT INTO franchise (name) VALUES (?)`, [franchise.name]); 176 | franchise.id = franchiseResult.insertId; 177 | 178 | for (const admin of franchise.admins) { 179 | await this.query(connection, `INSERT INTO userRole (userId, role, objectId) VALUES (?, ?, ?)`, [admin.id, Role.Franchisee, franchise.id]); 180 | } 181 | 182 | return franchise; 183 | } finally { 184 | connection.end(); 185 | } 186 | } 187 | 188 | async deleteFranchise(franchiseId) { 189 | const connection = await this.getConnection(); 190 | try { 191 | await connection.beginTransaction(); 192 | try { 193 | await this.query(connection, `DELETE FROM store WHERE franchiseId=?`, [franchiseId]); 194 | await this.query(connection, `DELETE FROM userRole WHERE objectId=?`, [franchiseId]); 195 | await this.query(connection, `DELETE FROM franchise WHERE id=?`, [franchiseId]); 196 | await connection.commit(); 197 | } catch { 198 | await connection.rollback(); 199 | throw new StatusCodeError('unable to delete franchise', 500); 200 | } 201 | } finally { 202 | connection.end(); 203 | } 204 | } 205 | 206 | async getFranchises(authUser, page = 0, limit = 10, nameFilter = '*') { 207 | const connection = await this.getConnection(); 208 | 209 | const offset = page * limit; 210 | nameFilter = nameFilter.replace(/\*/g, '%'); 211 | 212 | try { 213 | let franchises = await this.query(connection, `SELECT id, name FROM franchise WHERE name LIKE ? LIMIT ${limit + 1} OFFSET ${offset}`, [nameFilter]); 214 | 215 | const more = franchises.length > limit; 216 | if (more) { 217 | franchises = franchises.slice(0, limit); 218 | } 219 | 220 | for (const franchise of franchises) { 221 | if (authUser?.isRole(Role.Admin)) { 222 | await this.getFranchise(franchise); 223 | } else { 224 | franchise.stores = await this.query(connection, `SELECT id, name FROM store WHERE franchiseId=?`, [franchise.id]); 225 | } 226 | } 227 | return [franchises, more]; 228 | } finally { 229 | connection.end(); 230 | } 231 | } 232 | 233 | async getUserFranchises(userId) { 234 | const connection = await this.getConnection(); 235 | try { 236 | let franchiseIds = await this.query(connection, `SELECT objectId FROM userRole WHERE role='franchisee' AND userId=?`, [userId]); 237 | if (franchiseIds.length === 0) { 238 | return []; 239 | } 240 | 241 | franchiseIds = franchiseIds.map((v) => v.objectId); 242 | const franchises = await this.query(connection, `SELECT id, name FROM franchise WHERE id in (${franchiseIds.join(',')})`); 243 | for (const franchise of franchises) { 244 | await this.getFranchise(franchise); 245 | } 246 | return franchises; 247 | } finally { 248 | connection.end(); 249 | } 250 | } 251 | 252 | async getFranchise(franchise) { 253 | const connection = await this.getConnection(); 254 | try { 255 | franchise.admins = await this.query(connection, `SELECT u.id, u.name, u.email FROM userRole AS ur JOIN user AS u ON u.id=ur.userId WHERE ur.objectId=? AND ur.role='franchisee'`, [franchise.id]); 256 | 257 | franchise.stores = await this.query(connection, `SELECT s.id, s.name, COALESCE(SUM(oi.price), 0) AS totalRevenue FROM dinerOrder AS do JOIN orderItem AS oi ON do.id=oi.orderId RIGHT JOIN store AS s ON s.id=do.storeId WHERE s.franchiseId=? GROUP BY s.id`, [franchise.id]); 258 | 259 | return franchise; 260 | } finally { 261 | connection.end(); 262 | } 263 | } 264 | 265 | async createStore(franchiseId, store) { 266 | const connection = await this.getConnection(); 267 | try { 268 | const insertResult = await this.query(connection, `INSERT INTO store (franchiseId, name) VALUES (?, ?)`, [franchiseId, store.name]); 269 | return { id: insertResult.insertId, franchiseId, name: store.name }; 270 | } finally { 271 | connection.end(); 272 | } 273 | } 274 | 275 | async deleteStore(franchiseId, storeId) { 276 | const connection = await this.getConnection(); 277 | try { 278 | await this.query(connection, `DELETE FROM store WHERE franchiseId=? AND id=?`, [franchiseId, storeId]); 279 | } finally { 280 | connection.end(); 281 | } 282 | } 283 | 284 | getOffset(currentPage = 1, listPerPage) { 285 | return (currentPage - 1) * [listPerPage]; 286 | } 287 | 288 | getTokenSignature(token) { 289 | const parts = token.split('.'); 290 | if (parts.length > 2) { 291 | return parts[2]; 292 | } 293 | return ''; 294 | } 295 | 296 | async query(connection, sql, params) { 297 | const [results] = await connection.execute(sql, params); 298 | return results; 299 | } 300 | 301 | async getID(connection, key, value, table) { 302 | const [rows] = await connection.execute(`SELECT id FROM ${table} WHERE ${key}=?`, [value]); 303 | if (rows.length > 0) { 304 | return rows[0].id; 305 | } 306 | throw new Error('No ID found'); 307 | } 308 | 309 | async getConnection() { 310 | // Make sure the database is initialized before trying to get a connection. 311 | await this.initialized; 312 | return this._getConnection(); 313 | } 314 | 315 | async _getConnection(setUse = true) { 316 | const connection = await mysql.createConnection({ 317 | host: config.db.connection.host, 318 | user: config.db.connection.user, 319 | password: config.db.connection.password, 320 | connectTimeout: config.db.connection.connectTimeout, 321 | decimalNumbers: true, 322 | }); 323 | if (setUse) { 324 | await connection.query(`USE ${config.db.connection.database}`); 325 | } 326 | return connection; 327 | } 328 | 329 | async initializeDatabase() { 330 | try { 331 | const connection = await this._getConnection(false); 332 | try { 333 | const dbExists = await this.checkDatabaseExists(connection); 334 | console.log(dbExists ? 'Database exists' : 'Database does not exist, creating it'); 335 | 336 | await connection.query(`CREATE DATABASE IF NOT EXISTS ${config.db.connection.database}`); 337 | await connection.query(`USE ${config.db.connection.database}`); 338 | 339 | if (!dbExists) { 340 | console.log('Successfully created database'); 341 | } 342 | 343 | for (const statement of dbModel.tableCreateStatements) { 344 | await connection.query(statement); 345 | } 346 | 347 | if (!dbExists) { 348 | const defaultAdmin = { name: '常用名字', email: 'a@jwt.com', password: 'admin', roles: [{ role: Role.Admin }] }; 349 | this.addUser(defaultAdmin); 350 | } 351 | } finally { 352 | connection.end(); 353 | } 354 | } catch (err) { 355 | console.error(JSON.stringify({ message: 'Error initializing database', exception: err.message, connection: config.db.connection })); 356 | } 357 | } 358 | 359 | async checkDatabaseExists(connection) { 360 | const [rows] = await connection.execute(`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`, [config.db.connection.database]); 361 | return rows.length > 0; 362 | } 363 | } 364 | 365 | const db = new DB(); 366 | module.exports = { Role, DB: db }; 367 | --------------------------------------------------------------------------------