diff --git a/client/public/auth.mjs b/client/public/auth.mjs index a81f562..67ab9f5 100644 --- a/client/public/auth.mjs +++ b/client/public/auth.mjs @@ -49,7 +49,6 @@ export async function InitAuth0() { if (isAuthenticated) { const user = await auth0.getUser(); localStorage.setItem('user', user.given_name || user.nickname); - NotifyNavbar('login', user); localStorage.setItem('loggedIn', true); ready = true; @@ -67,6 +66,7 @@ export async function InitAuth0() { const res = await fetch('/api/auth/login', fetchOptions).then(res => res.json()); localStorage.setItem('admin', res.user.admin); + NotifyNavbar('login', user); } } diff --git a/client/public/components/css/stock-audit.css b/client/public/components/css/stock-audit.css new file mode 100644 index 0000000..eb51dca --- /dev/null +++ b/client/public/components/css/stock-audit.css @@ -0,0 +1,70 @@ +.stock-editor { + display: flex; + flex-direction: column; + width: 100%; +} + +.stock-header { + display: flex; + width: 100%; + flex-direction: column; + margin-top: 20px; + font-size: 2em; + border-bottom: 1px solid #ccc; +} + +.collapsible-menu { + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + margin-top: 1em; +} + +.menu-header { + width: 100%; + display: flex; + justify-content: space-between; + align-items: flex-end; + font-size: 1.5em; + font-weight: bold; + cursor: pointer; + border-bottom: #1A1A1A solid 1px; + min-width: 0; +} + +.menu-header-arrow { + transform: rotate(-180deg); + margin-left: 0.5em; + transition: transform 0.2s ease-in-out; +} + +/* rotate the arrow down when the details are open */ +.menu-header-arrow-down { + margin-left: 0.5em; + transform: rotate(-90deg); +} + +.menu-content { + max-width: fit-content; + display: none; + flex-direction: column; + align-items: flex-start; + min-width: 0; + margin-top: 1em; +} + +.details-open { + display: flex; + position: static; + width: auto; +} + +.product-details-content-item { + padding-top: 0.6em; +} + +.menu-content-item { + width: 100%; + margin-bottom: 1em; +} \ No newline at end of file diff --git a/client/public/components/navbar.mjs b/client/public/components/navbar.mjs index 0dc5d91..8a2152c 100644 --- a/client/public/components/navbar.mjs +++ b/client/public/components/navbar.mjs @@ -54,8 +54,8 @@ class NavBar extends Component { ${localStorage.user}▾ `; diff --git a/client/public/components/order-list.mjs b/client/public/components/order-list.mjs index 4380603..5081535 100644 --- a/client/public/components/order-list.mjs +++ b/client/public/components/order-list.mjs @@ -9,30 +9,48 @@ class OrderList extends Component { } async OnMount() { + const doStaffList = this.state.staff !== undefined; + const options = { method: 'GET', headers: { Authorization: `Bearer ${await Auth.GetToken()}` }, }; + if (doStaffList) { + const res = await fetch('/api/auth/staff/orders', options).then(res => res.json()); - const res = await fetch('/api/auth/orders', options).then(res => res.json()); + this.setState({ + ...this.getState, + orders: res.data, + title: 'Orders left to fufill', + none: 'All done :)', + }, false); + } else { + const res = await fetch('/api/auth/orders', options).then(res => res.json()); - console.log(res); - - this.setState({ - ...this.getState, - orders: res.data, - }, false); - console.log(this.state); + this.setState({ + ...this.getState, + orders: res.data, + title: 'Your Orders', + none: 'You have no orders', + }, false); + } } Render() { return { template: /* html */`
- Your Orders + {this.state.title}
+ ${this.state.orders.length === 0 + ? /* html */` +
+ {this.state.none} +
+ ` + : ''} ${this.state.orders.map(order => /* html */`
@@ -43,6 +61,12 @@ class OrderList extends Component {
Paid: £${parseFloat(order.subtotal_paid).toFixed(2)} Shipped? ${order.shipped ? 'Yes' : 'No'} + ${this.state.staff !== undefined + ? /* html */` + Posted? + Done & Recieved? + ` + : ''}
@@ -54,6 +78,69 @@ class OrderList extends Component { } OnRender() { + this.root.querySelectorAll('.order-list-item-shipped-checker').forEach(checkbox => { + checkbox.addEventListener('click', async (event) => { + const orderID = checkbox.parentElement.parentElement.parentElement.parentElement.parentElement.querySelector('.order-list-item-header-title').innerText.split('#')[1]; + const options = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await Auth.GetToken()}`, + }, + body: JSON.stringify({ + status: { + shipped: true, + }, + }), + }; + const orderUpdate = await fetch(`/api/auth/staff/order/${orderID}`, options).then(res => res.json()); + + const getOptions = { + method: 'GET', + headers: { Authorization: `Bearer ${await Auth.GetToken()}` }, + }; + const res = await fetch('/api/auth/staff/orders', getOptions).then(res => res.json()); + + this.setState({ + ...this.getState, + orders: res.data, + title: 'Orders left to fufill', + none: 'All done :)', + }); + }); + }); + + this.root.querySelectorAll('.order-list-item-done-checker').forEach(checkbox => { + checkbox.addEventListener('click', async (event) => { + const orderID = checkbox.parentElement.parentElement.parentElement.parentElement.parentElement.querySelector('.order-list-item-header-title').innerText.split('#')[1]; + const options = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await Auth.GetToken()}`, + }, + body: JSON.stringify({ + status: { + completed: true, + }, + }), + }; + const orderUpdate = await fetch(`/api/auth/staff/order/${orderID}`, options).then(res => res.json()); + + const getOptions = { + method: 'GET', + headers: { Authorization: `Bearer ${await Auth.GetToken()}` }, + }; + const res = await fetch('/api/auth/staff/orders', getOptions).then(res => res.json()); + + this.setState({ + ...this.getState, + orders: res.data, + title: 'Orders left to fufill', + none: 'All done :)', + }); + }); + }); } } diff --git a/client/public/components/order.mjs b/client/public/components/order.mjs index ee82c4b..04dfb6c 100644 --- a/client/public/components/order.mjs +++ b/client/public/components/order.mjs @@ -8,7 +8,7 @@ class Order extends Component { } async OnMount() { - // get order id from search param + // get order id from search param const query = new URLSearchParams(window.location.search); const id = query.get('id'); @@ -20,7 +20,6 @@ class Order extends Component { ...res, }, false); - console.log(this.state); localStorage.setItem('viewing-order', JSON.stringify({ items: res.items })); } @@ -86,22 +85,22 @@ class Order extends Component {
-
-
+
+
Posted - + ${this.state.shipped ? new Date(this.state.date_shipped).toDateString() : ''}
-
-
+
+
Delivered - + ${this.state.recieved ? new Date(this.state.date_recieved).toDateString() : ''}
@@ -118,7 +117,6 @@ class Order extends Component { } OnRender() { - // todo: add order tracking, the data is already there } OnUnMount() { diff --git a/client/public/components/stock-audit.mjs b/client/public/components/stock-audit.mjs new file mode 100644 index 0000000..d212b7c --- /dev/null +++ b/client/public/components/stock-audit.mjs @@ -0,0 +1,72 @@ +import { RegisterComponent, Component, SideLoad } from './components.mjs'; + +class StockEditor extends Component { + static __IDENTIFY() { return 'stock-editor'; } + + constructor() { + super(StockEditor); + } + + Render() { + return { + template: /* html */` +
+
Stock Editor
+
+ + +
+ + +
+ `, + style: SideLoad('/components/css/stock-audit.css'), + }; + } + + OnRender() { + const collapseButton = this.root.querySelectorAll('.menu-header'); + + collapseButton.forEach(el => el.addEventListener('click', (e) => { + const parent = e.path[2].querySelector('.collapsible-menu') ? e.path[1] : e.path[2]; + const collapseContent = parent.querySelector('.menu-content'); + const collapseArrow = parent.querySelector('.menu-header-arrow'); + collapseContent.classList.toggle('details-open'); + collapseArrow.classList.toggle('menu-header-arrow-down'); + })); + + // remove + const removeStockLookup = this.root.querySelector('.stock-lookup-button'); + removeStockLookup.addEventListener('click', () => { + const preview = this.root.querySelector('.stock-remove-preview'); + const id = this.root.querySelector('.menu-content-item-id-input').value; + const type = this.root.querySelector('.menu-content-item-type-input').value; + + preview.setAttribute('id', id); + preview.setAttribute('type', type); + }); + } +} + +RegisterComponent(StockEditor); diff --git a/client/public/components/super-compact-listing.mjs b/client/public/components/super-compact-listing.mjs index 76fe783..9599965 100644 --- a/client/public/components/super-compact-listing.mjs +++ b/client/public/components/super-compact-listing.mjs @@ -8,7 +8,7 @@ class SuperCompactProductListing extends Component { super(SuperCompactProductListing); } - async OnMount() { + async Update() { if (!this.state.name || !this.state.price) { const product = (await fetch(`/api/${this.state.type}/${this.state.id}`).then(res => res.json())).data; const name = product.name; @@ -28,7 +28,12 @@ class SuperCompactProductListing extends Component { colours, quantity: product.quantity, }, false); - } else if (this.state.tags) { + } + + if (this.state.tags) { + if (this.state.tags.length >= 1) { + return; + } const tags = JSON.parse(this.state.tags); this.setState({ ...this.getState, diff --git a/client/public/orders/index.html b/client/public/orders/index.html index 7883009..91d5a2b 100644 --- a/client/public/orders/index.html +++ b/client/public/orders/index.html @@ -1,6 +1,6 @@ - LegoLog Basket + LegoLog Your Orders diff --git a/client/public/orders/order/index.html b/client/public/orders/order/index.html index 7493d7a..b0b6360 100644 --- a/client/public/orders/order/index.html +++ b/client/public/orders/order/index.html @@ -1,6 +1,6 @@ - LegoLog Basket + LegoLog Order diff --git a/client/public/staff/revieworders/index.html b/client/public/staff/revieworders/index.html new file mode 100644 index 0000000..f55f0d3 --- /dev/null +++ b/client/public/staff/revieworders/index.html @@ -0,0 +1,37 @@ + + + LegoLog Logistical Fufillment Service + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/staff/stock/index.html b/client/public/staff/stock/index.html new file mode 100644 index 0000000..767cb0f --- /dev/null +++ b/client/public/staff/stock/index.html @@ -0,0 +1,37 @@ + + + LegoLog Stock Editor + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/API.md b/docs/API.md index a314694..93ab50a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,27 +10,30 @@ automatically every request | Type | Route | Queries | Auth? | Notes | | --- | --- | --- | - | --- | -| GET | /api/special/ | | ❌ | | -| GET | /api/search/ | query (q), page | ❌ | Query endpoint | -| GET | /api/sets/featured | page | ❌ | Query endpoint | -| GET | /api/brick/:id | | ❌ | | -| POST | /api/bulk/brick | array | ❌ | POST due to bulk nature | -| GET | /api/set/:id | | ❌ | | -| GET | /api/cdn/:id | | ❌ | | -| GET | /api/basket/price/ | | ❌ | | -| GET | /api/discount/ | offer code | ❌ | | -| POST | /api/order/ | | ❌ | IF user is authenticated, auth/bearer will be sent and done manually without middleware | -| GET | /api/auth/order/:id | | ❌ | Security By Obscurity | -| GET | /api/auth/login/ | | ✔️ | | -| GET | /api/auth/orders/ | | ✔️ | | +| GET | /api/special/ | | ❌ | | +| GET | /api/search/ | query (q), page | ❌ | Query endpoint | +| GET | /api/sets/featured | page | ❌ | Query endpoint | +| GET | /api/brick/:id | | ❌ | | +| POST | /api/bulk/brick | array | ❌ | POST due to bulk nature | +| GET | /api/set/:id | | ❌ | | +| GET | /api/cdn/:id | | ❌ | | +| GET | /api/basket/price/ | | ❌ | | +| GET | /api/discount/ | offer code | ❌ | | +| POST | /api/order/ | | ❌ | IF user is authenticated, auth/bearer will be sent and done manually without middleware | +| GET | /api/auth/order/:id | | ❌ | Security By Obscurity | +| GET | /api/auth/login/ | | ✔️ | | +| GET | /api/auth/orders/ | | ✔️ | | +| GET | /api/auth/staff/orders/ | | ✔️ | All unshipped orders | +| PUT | /api/auth/staff/order/:id | | ✔️ | Update order to shipped, recieved (carrier) | +| PUT | /api/auth/staff/stock/:type/:id | | ✔️ | Update stock on item | +| POST | /api/auth/staff/stock/ | | ✔️ | Add item to inventory | +| DEL | /api/auth/staff/stock/:type/:id | | ✔️ | Remove item from inventory | Query endpoints do not return the full data on a brick/set, they return a subset for product listing pages ## Query structure -## Query parameters - For all endpoints that query, the following parameters are supported: q: string to search for (fuzzy) diff --git a/src/controllers/controller-master.js b/src/controllers/controller-master.js index 6e8b10d..c07ed0f 100644 --- a/src/controllers/controller-master.js +++ b/src/controllers/controller-master.js @@ -54,6 +54,7 @@ function LevenshteinDistance(s, t) { // TODO: get this working properly function SanatiseQuery(query) { + if (!query) return ''; query = query.trim(); query = query.replace(/[^a-zA-Z0-9,&/\s]/g, ''); query = escape(query); diff --git a/src/controllers/order-controller.js b/src/controllers/order-controller.js index 17da9e8..ce102a6 100644 --- a/src/controllers/order-controller.js +++ b/src/controllers/order-controller.js @@ -115,6 +115,7 @@ async function GetOrdersByUser(userId) { discount, date_placed, shipped FROM order_log WHERE order_log.user_id = $1 + ORDER BY date_placed DESC `, [userId]).catch(() => { return { error: 'Database error', @@ -133,14 +134,76 @@ async function GetOrdersByUser(userId) { return result; } +async function GetUnFinishedOrders() { + await Database.Query('BEGIN TRANSACTION;'); + const dbres = await Database.Query(` + SELECT order_log.id, order_log.user_id, subtotal_paid, + date_placed, shipped, recieved + FROM order_log + WHERE order_log.recieved = FALSE + ORDER BY date_placed DESC + `).catch(() => { + return { + error: 'Database error', + }; + }); + if (dbres.error) { + Database.Query('ROLLBACK TRANSACTION;'); + Logger.Error(dbres.error); + return { + error: 'Database error', + }; + } + Database.Query('COMMIT TRANSACTION;'); + + const result = dbres.rows; + return result; +} + // U async function OrderShipped(orderid) { - + await Database.Query('BEGIN TRANSACTION;'); + const dbres = await Database.Query(` + UPDATE order_log + SET shipped = TRUE, date_shipped = NOW() + WHERE id = $1 + `, [orderid]).catch(() => { + return { + error: 'Database error', + }; + }); + if (dbres.error) { + Database.Query('ROLLBACK TRANSACTION;'); + Logger.Error(dbres.error); + return { + error: 'Database error', + }; + } + Database.Query('COMMIT TRANSACTION;'); + return true; } async function OrderRecieved(orderid) { - + await Database.Query('BEGIN TRANSACTION;'); + const dbres = await Database.Query(` + UPDATE order_log + SET recieved = TRUE, date_recieved = NOW() + WHERE id = $1 + `, [orderid]).catch(() => { + return { + error: 'Database error', + }; + }); + if (dbres.error) { + Database.Query('ROLLBACK TRANSACTION;'); + Logger.Error(dbres.error); + return { + error: 'Database error', + }; + } + Database.Query('COMMIT TRANSACTION;'); + return true; } // D @@ -149,6 +212,7 @@ module.exports = { NewOrder, GetOrderById, GetOrdersByUser, + GetUnFinishedOrders, OrderShipped, OrderRecieved, }; diff --git a/src/routes/api.js b/src/routes/api.js index 55ac369..6335b0f 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -31,6 +31,9 @@ function Init() { Server.App.post('/api/auth/order/', Auth0.JWTMiddleware, Order.ProcessNew); Server.App.get('/api/auth/orders/', Auth0.JWTMiddleware, Order.GetOrders); + Server.App.get('/api/auth/staff/orders/', Auth0.JWTMiddleware, Auth0.AdminOnlyEndpoint, Order.GetUnFinishedOrders); + Server.App.put('/api/auth/staff/order/:id', Auth0.JWTMiddleware, Auth0.AdminOnlyEndpoint, Order.UpdateOrderStatus); + Logger.Module('API', 'API Routes Initialized'); } diff --git a/src/routes/auth0-router.js b/src/routes/auth0-router.js index c5eadcd..ae37191 100644 --- a/src/routes/auth0-router.js +++ b/src/routes/auth0-router.js @@ -13,6 +13,10 @@ const AUTH0CONFIG = { audience: 'localhost:8080/api', }; +// Auth0 was rate limiting me a LOT, so I'm going to use a cache to +// prevent that from happening again +const Auth0UserCache = []; + const JWTChecker = OAuth2JWTBearer.auth({ audience: AUTH0CONFIG.audience, issuerBaseURL: `https://${AUTH0CONFIG.domain}`, @@ -33,6 +37,25 @@ function JWTMiddleware(req, res, next) { }); } +async function AdminOnlyEndpoint(req, res, next) { + const user = await Auth0GetUser(req); + if (!user) { + return res.send({ + error: 'No user found', + }); + } + + const localUser = await Controller.GetUserByID(user.sub.split('|')[1]); + + if (!localUser.admin) { + return res.send({ + error: 'Unauthorized', + }); + } + + next(); +} + async function Auth0GetUser(req) { if (!req.auth) { return null; @@ -40,14 +63,21 @@ async function Auth0GetUser(req) { if (!req.auth || !req.auth.token) return null; + const token = req.auth.token; + if (Auth0UserCache[token]) { + return Auth0UserCache[token]; + } + try { const response = await Axios.get(`https://${AUTH0CONFIG.domain}/userinfo`, { method: 'GET', headers: { - authorization: `Bearer ${req.auth.token}`, + authorization: `Bearer ${token}`, }, }); + Auth0UserCache[token] = response.data; + return response.data; } catch (err) { Logger.Error('error getting auth profile', req.auth, err); @@ -58,6 +88,11 @@ async function Auth0GetUser(req) { async function Login(req, res) { // tell the database the user is new if they don't already exist const user = await Auth0GetUser(req); + if (!user) { + return res.send({ + error: 'No user found', + }); + } const id = user.sub.split('|')[1]; @@ -91,6 +126,7 @@ async function Login(req, res) { module.exports = { JWTMiddleware, + AdminOnlyEndpoint, Auth0GetUser, Login, }; diff --git a/src/routes/order-router.js b/src/routes/order-router.js index c6ade31..5352a60 100644 --- a/src/routes/order-router.js +++ b/src/routes/order-router.js @@ -115,7 +115,7 @@ async function ProcessNew(req, res) { discount: 0, }; if (discountCode !== null) { - const sanatisedCode = ControllerMaster.SanatiseQuery(req.query.code); + const sanatisedCode = ControllerMaster.SanatiseQuery(discountCode); discount = await MiscController.GetDiscount(sanatisedCode); @@ -188,8 +188,76 @@ async function GetOrders(req, res) { }); } +async function GetUnFinishedOrders(req, res) { + const orders = await OrderController.GetUnFinishedOrders(); + + if (orders.error) { + return res.send({ + error: orders.error, + }); + } + + return res.send({ + data: orders, + }); +} + +async function UpdateOrderStatus(req, res) { + const orderId = req.params.id; + const status = req.body.status; + + if (!orderId) { + return res.send({ + error: 'No order id in request', + }); + } + + if (!status) { + return res.send({ + error: 'No status in request', + }); + } + + const shipped = status.shipped; + if (!shipped) { + const completed = status.completed; + if (!completed) { + return res.send({ + error: 'No status in request', + }); + } + const orderRecieved = await OrderController.OrderRecieved(orderId); + if (orderRecieved.error) { + return res.send({ + error: orderRecieved.error, + }); + } + + return res.send({ + data: { + success: true, + }, + }); + } + + const orderShipped = await OrderController.OrderShipped(orderId); + if (orderShipped.error) { + return res.send({ + error: orderShipped.error, + }); + } + + return res.send({ + data: { + success: true, + }, + }); +} + module.exports = { ProcessNew, GetOrder, GetOrders, + GetUnFinishedOrders, + UpdateOrderStatus, };