diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/backend/.dockerignore b/backend/.dockerignore old mode 100755 new mode 100644 diff --git a/backend/Dockerfile b/backend/Dockerfile old mode 100755 new mode 100644 diff --git a/backend/app.js b/backend/app.js new file mode 100644 index 0000000..c2b02bc --- /dev/null +++ b/backend/app.js @@ -0,0 +1,46 @@ +require("dotenv").config(); +const express = require("express"); +const cors = require("cors"); + + + +const app = express(); +app.use(express.json()); + +const allowedOrigins = [ + "http://localhost:3000", + "https://costco.nicosaya.com", + "https://costco.api.nicosaya.com", +]; +app.use( + cors({ + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (allowedOrigins.includes(origin)) return callback(null, true); + if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + callback(new Error("Not allowed by CORS")); + }, + methods: ["GET", "POST", "PUT", "DELETE"], + }) +); + +app.get('/', async (req, res) => { + res.status(200).send('Grocery List API is running.'); +}); + + +const authRoutes = require("./routes/auth.routes"); +app.use("/auth", authRoutes); + +const listRoutes = require("./routes/list.routes"); +app.use("/list", listRoutes); + +const adminRoutes = require("./routes/admin.routes"); +app.use("/admin", adminRoutes); + +const usersRoutes = require("./routes/users.routes"); +app.use("/users", usersRoutes); + + +module.exports = app; \ No newline at end of file diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js new file mode 100644 index 0000000..9987f67 --- /dev/null +++ b/backend/controllers/auth.controller.js @@ -0,0 +1,33 @@ +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const User = require("../models/user.model"); + +exports.register = async (req, res) => { + const { email, password, role } = req.body; + + try { + const hash = await bcrypt.hash(password, 10); + const user = await User.createUser(email, hash, role); + res.json({ message: "User registered", user }); + } catch (err) { + res.status(400).json({ message: "Registration failed", error: err }); + } +}; + +exports.login = async (req, res) => { + const { email, password } = req.body; + + const user = await User.findByEmail(email); + if (!user) return res.status(401).json({ message: "Invalid credentials" }); + + const valid = await bcrypt.compare(password, user.password); + if (!valid) return res.status(401).json({ message: "Invalid credentials" }); + + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: "1d" } + ); + + res.json({ token, role: user.role }); +}; diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js new file mode 100644 index 0000000..2dfe2c2 --- /dev/null +++ b/backend/controllers/lists.controller.js @@ -0,0 +1,24 @@ +const List = require("../models/list.model"); + + +exports.getList = async (req, res) => { + const items = await List.getUnboughtItems(); + res.json(items); +}; + + +exports.addItem = async (req, res) => { + const { item_name, quantity } = req.body; + + const id = await List.addOrUpdateItem(item_name, quantity); + + await List.addHistoryRecord(id, quantity); + + res.json({ message: "Item added/updated" }); +}; + + +exports.markBought = async (req, res) => { + await List.setBought(req.body.id); + res.json({ message: "Item marked bought" }); +}; diff --git a/backend/controllers/users.controller.js b/backend/controllers/users.controller.js new file mode 100644 index 0000000..6f7f460 --- /dev/null +++ b/backend/controllers/users.controller.js @@ -0,0 +1,6 @@ +const User = require("../models/user.model"); + +exports.getAllUsers = async (req, res) => { + const users = await User.getAllUsers(); + res.json(users); +}; diff --git a/backend/db/pool.js b/backend/db/pool.js new file mode 100644 index 0000000..38df9bf --- /dev/null +++ b/backend/db/pool.js @@ -0,0 +1,11 @@ +const { Pool } = require("pg"); + +const pool = new Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASS, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + port: 5432, +}); + +module.exports = pool; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..8e2caf1 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,19 @@ +const jwt = require("jsonwebtoken"); + +function auth(req, res, next) { + const header = req.headers.authorization; + if (!header) return res.status(401).json({ message: "Missing token" }); + + const token = header.split(" ")[1]; + if (!token) return res.status(401).json({ message: "Invalid token format" }); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; // id + role + next(); + } catch (err) { + res.status(401).json({ message: "Invalid or expired token" }); + } +} + +module.exports = auth; diff --git a/backend/middleware/rbac.js b/backend/middleware/rbac.js new file mode 100644 index 0000000..1d8205a --- /dev/null +++ b/backend/middleware/rbac.js @@ -0,0 +1,11 @@ +function requireRole(...allowedRoles) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ message: "Authentication required" }); + if (!allowedRoles.includes(req.user.role)) + return res.status(403).json({ message: "Forbidden" }); + + next(); + }; +} + +module.exports = requireRole; diff --git a/backend/models/list.model.js b/backend/models/list.model.js new file mode 100644 index 0000000..e454c03 --- /dev/null +++ b/backend/models/list.model.js @@ -0,0 +1,46 @@ +const pool = require("../db/pool"); + + +exports.getUnboughtItems = async () => { + const result = await pool.query( + "SELECT * FROM grocery_list WHERE bought = FALSE ORDER BY id ASC" + ); + return result.rows; +}; + + +exports.addOrUpdateItem = async (item_name, quantity) => { + const result = await pool.query( + "SELECT id, bought FROM grocery_list WHERE item_name = $1", + [item_name] + ); + + if (result.rowCount > 0) { + await pool.query( + "UPDATE grocery_list SET quantity = $1, bought = FALSE WHERE id = $2", + [quantity, result.rows[0].id] + ); + return result.rows[0].id; + } else { + const insert = await pool.query( + "INSERT INTO grocery_list (item_name, quantity) VALUES ($1, $2) RETURNING id", + [item_name, quantity] + ); + return insert.rows[0].id; + } +}; + + +exports.setBought = async (id) => { + await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]); +}; + + +exports.addHistoryRecord = async (itemId, quantity) => { + await pool.query( + `INSERT INTO grocery_history (list_item_id, quantity, added_on) + VALUES ($1, $2, NOW())`, + [itemId, quantity] + ); +}; + diff --git a/backend/models/user.model.js b/backend/models/user.model.js new file mode 100644 index 0000000..f82aa1b --- /dev/null +++ b/backend/models/user.model.js @@ -0,0 +1,22 @@ +const pool = require("../db/pool"); + +exports.findByUsername = async (username) => { + const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); + return result.rows[0]; +}; + +exports.createUser = async (username, hashedPassword, name, role = "viewer") => { + const result = await pool.query( + `INSERT INTO users (username, password, name, role) + VALUES ($1, $2, $3, $4) + RETURNING id, username, role`, + [username, hashedPassword, name, role] + ); + return result.rows[0]; +}; + + +exports.getAllUsers = async () => { + const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC"); + return result.rows; +}; diff --git a/backend/package-lock.json b/backend/package-lock.json old mode 100755 new mode 100644 index 613173b..f25dc1b --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -5,9 +5,11 @@ "packages": { "": { "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.16.0" }, "devDependencies": { @@ -607,6 +609,14 @@ "node": ">=0.10.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", @@ -669,6 +679,11 @@ "node": ">=0.10.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1044,6 +1059,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1906,6 +1929,46 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -1918,6 +1981,41 @@ "node": ">=0.10.0" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -3331,7 +3429,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "bin": { "semver": "bin/semver.js" }, diff --git a/backend/package.json b/backend/package.json old mode 100755 new mode 100644 index 2bcdf1a..bb5ca49 --- a/backend/package.json +++ b/backend/package.json @@ -1,8 +1,10 @@ { "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.16.0" }, "devDependencies": { diff --git a/backend/routes/admin.routes.js b/backend/routes/admin.routes.js new file mode 100644 index 0000000..83a9e7b --- /dev/null +++ b/backend/routes/admin.routes.js @@ -0,0 +1,9 @@ +const router = require("express").Router(); +const auth = require("../middleware/auth"); +const requireRole = require("../middleware/rbac"); +const usersController = require("../controllers/users.controller"); + +// Admin-only route +router.get("/users", auth, requireRole("admin"), usersController.getAllUsers); + +module.exports = router; diff --git a/backend/routes/auth.routes.js b/backend/routes/auth.routes.js new file mode 100644 index 0000000..74eaf5c --- /dev/null +++ b/backend/routes/auth.routes.js @@ -0,0 +1,7 @@ +const router = require("express").Router(); +const controller = require("../controllers/auth.controller"); + +router.post("/register", controller.register); +router.post("/login", controller.login); + +module.exports = router; diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js new file mode 100644 index 0000000..74cfd65 --- /dev/null +++ b/backend/routes/list.routes.js @@ -0,0 +1,14 @@ +const router = require("express").Router(); +const controller = require("../controllers/lists.controller"); +const auth = require("../middleware/auth"); +const requireRole = require("../middleware/rbac"); + + +router.get("/", auth, requireRole("viewer", "editor", "admin"), controller.getList); + + +router.post("/add", auth, requireRole("editor", "admin"), controller.addItem); +router.post("/mark-bought", auth, requireRole("editor", "admin"), controller.markBought); + + +module.exports = router; diff --git a/backend/routes/users.routes.js b/backend/routes/users.routes.js new file mode 100644 index 0000000..eea991a --- /dev/null +++ b/backend/routes/users.routes.js @@ -0,0 +1,8 @@ +const router = require("express").Router(); +const auth = require("../middleware/auth"); +const requireRole = require("../middleware/rbac"); +const usersController = require("../controllers/users.controller"); + +router.get("/", auth, requireRole("admin"), usersController.getAllUsers); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js old mode 100755 new mode 100644 index 6bb384c..98c98f6 --- a/backend/server.js +++ b/backend/server.js @@ -1,103 +1,7 @@ -require('dotenv').config(); +const app = require("./app"); -const express = require('express'); -const cors = require('cors'); -const { Pool } = require('pg'); +const PORT = process.env.PORT || 5000; -const app = express(); -const port = 5000; - -const pool = new Pool({ - user: process.env.DB_USER, - password: process.env.DB_PASS, - host: process.env.DB_HOST, - database: process.env.DB_NAME, - port: 5432, -}); - - - -app.use(express.json()); - -const allowedOrigins = [ - "http://localhost:3000", - "https://costco.nicosaya.com", - "https://costco.api.nicosaya.com", -]; -app.use(cors({ - origin: function (origin, callback) { - if (!origin) return callback(null, true); - if (allowedOrigins.includes(origin)) return callback(null, true); - if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - callback(new Error("Not allowed by CORS")); - }, - methods: ["GET", "POST"], -})); - - - -app.get('/', async (req, res) => { - const { query } = req.query; - const { rows } = await pool.query( - 'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10', - [`%${query}%`] - ); - res.status(200).send('Grocery List API is running.'); -}); - - -app.get('/suggest', async (req, res) => { - const { query } = req.query; - const { rows } = await pool.query( - 'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10', - [`%${query}%`] - ); - res.json(rows.map(r => r.item_name)); -}); - - -app.post('/add', async (req, res) => { - const { item_name, quantity } = req.body; - const result = await pool.query( - 'SELECT id, bought FROM grocery_list WHERE item_name = $1', - [item_name] - ); - - let listItemId; - if (result.rowCount > 0) { - listItemId = result.rows[0].id; - await pool.query( - 'UPDATE grocery_list SET quantity = $1, bought = FALSE WHERE id = $2', - [quantity, listItemId] - ); - res.json({ message: 'Item re-added with updated quantity.' }); - } else { - const insertResult = await pool.query( - 'INSERT INTO grocery_list (item_name, quantity) VALUES ($1, $2) RETURNING id', - [item_name, quantity] - ); - listItemId = insertResult.rows[0].id; - res.json({ message: 'Item added to list.' }); - } - - await pool.query( - 'INSERT INTO grocery_history (list_item_id, quantity, added_on) VALUES ($1, $2, NOW())', - [listItemId, quantity] - ); -}); - - -app.post('/mark-bought', async (req, res) => { - const { id } = req.body; - await pool.query('UPDATE grocery_list SET bought = TRUE WHERE id = $1', [id]); - res.json({ message: 'Item marked as bought.' }); -}); - - -app.get('/list', async (req, res) => { - const { rows } = await pool.query('SELECT * FROM grocery_list WHERE bought = FALSE'); - res.json(rows); -}); - -app.listen(port, () => console.log(`Listening at http://localhost:${port}`)); +app.listen(PORT, () => + console.log(`Backend running on http://localhost:${PORT}`) +); \ No newline at end of file diff --git a/build.js b/build.js deleted file mode 100755 index d6033bf..0000000 --- a/build.js +++ /dev/null @@ -1,15 +0,0 @@ -// build.js -const esbuild = require('esbuild'); -const fs = require('fs'); - -// Ensure dist folder exists -if (!fs.existsSync('dist')) fs.mkdirSync('dist', { recursive: true }); - -esbuild.build({ - entryPoints: ['server.js'], - outfile: 'dist/server.js', - bundle: true, - platform: 'node', - target: 'node18', - minify: false, -}).catch(() => process.exit(1)); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml old mode 100755 new mode 100644 index d0a3da9..259cc37 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,7 +16,9 @@ services: backend: - build: ./backend + build: + context: ./backend + command: npm run dev volumes: - ./backend:/app - /app/node_modules diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml old mode 100755 new mode 100644