diff --git a/backend/app.js b/backend/app.js index f6f128c..0ee9485 100644 --- a/backend/app.js +++ b/backend/app.js @@ -3,6 +3,7 @@ const cors = require("cors"); const path = require("path"); const User = require("./models/user.model"); const requestIdMiddleware = require("./middleware/request-id"); +const { sendError } = require("./utils/http"); const app = express(); app.use(requestIdMiddleware); @@ -27,12 +28,12 @@ app.use( }) ); -app.get('/', async (req, res) => { - resText = `Grocery List API is running.\n` + - `Roles available: ${Object.values(User.ROLES).join(', ')}` - - res.status(200).type("text/plain").send(resText); -}); +app.get('/', async (req, res) => { + const resText = `Grocery List API is running.\n` + + `Roles available: ${Object.values(User.ROLES).join(', ')}`; + + res.status(200).type("text/plain").send(resText); +}); const authRoutes = require("./routes/auth.routes"); @@ -53,7 +54,19 @@ app.use("/config", configRoutes); const householdsRoutes = require("./routes/households.routes"); app.use("/households", householdsRoutes); -const storesRoutes = require("./routes/stores.routes"); -app.use("/stores", storesRoutes); - +const storesRoutes = require("./routes/stores.routes"); +app.use("/stores", storesRoutes); + +app.use((err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + + const statusCode = err.status || err.statusCode || 500; + const message = + statusCode >= 500 ? "Internal server error" : err.message || "Request failed"; + + return sendError(res, statusCode, message); +}); + module.exports = app; diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 7574efe..742e55e 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -1,44 +1,45 @@ -const bcrypt = require("bcryptjs"); -const jwt = require("jsonwebtoken"); -const User = require("../models/user.model"); - +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const User = require("../models/user.model"); +const { sendError } = require("../utils/http"); + exports.register = async (req, res) => { let { username, password, name } = req.body; username = username.toLowerCase(); console.log(`Registration attempt for ${name} => username:${username}`); - - try { - const hash = await bcrypt.hash(password, 10); - const user = await User.createUser(username, hash, name); - console.log(`✅ User registered: ${username}`); - - res.json({ message: "User registered", user }); - } catch (err) { - res.status(400).json({ message: "Registration failed", error: err }); - } -}; - -exports.login = async (req, res) => { - let { username, password } = req.body; - - username = username.toLowerCase(); - const user = await User.findByUsername(username); - if (!user) { - console.log(`⚠️ Login attempt -> No user found: ${username}`); - return res.status(401).json({ message: "User not found" }); - } - + + try { + const hash = await bcrypt.hash(password, 10); + const user = await User.createUser(username, hash, name); + console.log(`User registered: ${username}`); + + res.json({ message: "User registered", user }); + } catch (err) { + sendError(res, 400, "Registration failed"); + } +}; + +exports.login = async (req, res) => { + let { username, password } = req.body; + + username = username.toLowerCase(); + const user = await User.findByUsername(username); + if (!user) { + console.log(`Login attempt with unknown user: ${username}`); + return sendError(res, 401, "User not found"); + } + const valid = await bcrypt.compare(password, user.password); if (!valid) { console.log(`Invalid login attempt for user ${username}`); - return res.status(401).json({ message: "Invalid credentials" }); + return sendError(res, 401, "Invalid credentials"); } - - const token = jwt.sign( - { id: user.id, role: user.role }, - process.env.JWT_SECRET, - { expiresIn: "1 year" } - ); - - res.json({ token, userId: user.id, username, role: user.role }); + + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: "1 year" } + ); + + res.json({ token, userId: user.id, username, role: user.role }); }; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 8e2caf1..1006f50 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,18 +1,19 @@ const jwt = require("jsonwebtoken"); +const { sendError } = require("../utils/http"); function auth(req, res, next) { const header = req.headers.authorization; - if (!header) return res.status(401).json({ message: "Missing token" }); + if (!header) return sendError(res, 401, "Missing token"); const token = header.split(" ")[1]; - if (!token) return res.status(401).json({ message: "Invalid token format" }); + if (!token) return sendError(res, 401, "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" }); + sendError(res, 401, "Invalid or expired token"); } } diff --git a/backend/middleware/household.js b/backend/middleware/household.js index ee3bc87..38ca922 100644 --- a/backend/middleware/household.js +++ b/backend/middleware/household.js @@ -1,4 +1,5 @@ const householdModel = require("../models/household.model"); +const { sendError } = require("../utils/http"); // Middleware to check if user belongs to household exports.householdAccess = async (req, res, next) => { @@ -7,16 +8,14 @@ exports.householdAccess = async (req, res, next) => { const userId = req.user.id; if (!householdId) { - return res.status(400).json({ error: "Household ID required" }); + return sendError(res, 400, "Household ID required"); } // Check if user is member of household const isMember = await householdModel.isHouseholdMember(householdId, userId); if (!isMember) { - return res.status(403).json({ - error: "Access denied. You are not a member of this household." - }); + return sendError(res, 403, "Access denied. You are not a member of this household."); } // Get user's role in household @@ -31,7 +30,7 @@ exports.householdAccess = async (req, res, next) => { next(); } catch (error) { console.error("Household access check error:", error); - res.status(500).json({ error: "Server error checking household access" }); + sendError(res, 500, "Server error checking household access"); } }; @@ -39,15 +38,15 @@ exports.householdAccess = async (req, res, next) => { exports.requireHouseholdRole = (...allowedRoles) => { return (req, res, next) => { if (!req.household) { - return res.status(500).json({ - error: "Household context not set. Use householdAccess middleware first." - }); + return sendError(res, 500, "Household context not set. Use householdAccess middleware first."); } if (!allowedRoles.includes(req.household.role)) { - return res.status(403).json({ - error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}` - }); + return sendError( + res, + 403, + `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}` + ); } next(); @@ -63,13 +62,11 @@ exports.storeAccess = async (req, res, next) => { const storeId = parseInt(req.params.storeId || req.params.sId); if (!storeId) { - return res.status(400).json({ error: "Store ID required" }); + return sendError(res, 400, "Store ID required"); } if (!req.household) { - return res.status(500).json({ - error: "Household context not set. Use householdAccess middleware first." - }); + return sendError(res, 500, "Household context not set. Use householdAccess middleware first."); } // Check if household has access to this store @@ -77,9 +74,7 @@ exports.storeAccess = async (req, res, next) => { const hasStore = await storeModel.householdHasStore(req.household.id, storeId); if (!hasStore) { - return res.status(403).json({ - error: "This household does not have access to this store." - }); + return sendError(res, 403, "This household does not have access to this store."); } // Attach store info to request @@ -90,20 +85,18 @@ exports.storeAccess = async (req, res, next) => { next(); } catch (error) { console.error("Store access check error:", error); - res.status(500).json({ error: "Server error checking store access" }); + sendError(res, 500, "Server error checking store access"); } }; // Middleware to require system admin role exports.requireSystemAdmin = (req, res, next) => { if (!req.user) { - return res.status(401).json({ error: "Authentication required" }); + return sendError(res, 401, "Authentication required"); } if (req.user.role !== 'system_admin') { - return res.status(403).json({ - error: "Access denied. System administrator privileges required." - }); + return sendError(res, 403, "Access denied. System administrator privileges required."); } next(); diff --git a/backend/middleware/image.js b/backend/middleware/image.js index 2983ced..8ae50d4 100644 --- a/backend/middleware/image.js +++ b/backend/middleware/image.js @@ -1,6 +1,7 @@ const multer = require("multer"); const sharp = require("sharp"); const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants"); +const { sendError } = require("../utils/http"); // Configure multer for memory storage (we'll process before saving to DB) const upload = multer({ @@ -42,7 +43,7 @@ const processImage = async (req, res, next) => { next(); } catch (error) { - res.status(400).json({ message: "Error processing image: " + error.message }); + sendError(res, 400, `Error processing image: ${error.message}`); } }; diff --git a/backend/middleware/rbac.js b/backend/middleware/rbac.js index 1d8205a..205de10 100644 --- a/backend/middleware/rbac.js +++ b/backend/middleware/rbac.js @@ -1,8 +1,10 @@ +const { sendError } = require("../utils/http"); + function requireRole(...allowedRoles) { return (req, res, next) => { - if (!req.user) return res.status(401).json({ message: "Authentication required" }); + if (!req.user) return sendError(res, 401, "Authentication required"); if (!allowedRoles.includes(req.user.role)) - return res.status(403).json({ message: "Forbidden" }); + return sendError(res, 403, "Forbidden"); next(); }; diff --git a/backend/middleware/request-id.js b/backend/middleware/request-id.js index 970d3fa..921d61a 100644 --- a/backend/middleware/request-id.js +++ b/backend/middleware/request-id.js @@ -1,4 +1,5 @@ const crypto = require("crypto"); +const { normalizeErrorPayload } = require("../utils/http"); function generateRequestId() { if (typeof crypto.randomUUID === "function") { @@ -25,10 +26,16 @@ function requestIdMiddleware(req, res, next) { const originalJson = res.json.bind(res); res.json = (payload) => { - if (isPlainObject(payload) && payload.request_id === undefined) { - return originalJson({ ...payload, request_id: requestId }); + const normalizedPayload = normalizeErrorPayload(payload, res.statusCode); + + if ( + isPlainObject(normalizedPayload) && + normalizedPayload.request_id === undefined + ) { + return originalJson({ ...normalizedPayload, request_id: requestId }); } - return originalJson(payload); + + return originalJson(normalizedPayload); }; next(); diff --git a/backend/routes/auth.routes.js b/backend/routes/auth.routes.js index 75db9de..03c5421 100644 --- a/backend/routes/auth.routes.js +++ b/backend/routes/auth.routes.js @@ -1,13 +1,14 @@ -const router = require("express").Router(); -const controller = require("../controllers/auth.controller"); +const router = require("express").Router(); +const controller = require("../controllers/auth.controller"); +const User = require("../models/user.model"); -router.post("/register", controller.register); -router.post("/login", controller.login); -router.post("/", async (req, res) => { - resText = `Grocery List API is running.\n` + - `Roles available: ${Object.values(User.ROLES).join(', ')}` - - res.status(200).type("text/plain").send(resText); -}); +router.post("/register", controller.register); +router.post("/login", controller.login); +router.post("/", async (req, res) => { + const resText = `Grocery List API is running.\n` + + `Roles available: ${Object.values(User.ROLES).join(', ')}`; + + res.status(200).type("text/plain").send(resText); +}); module.exports = router; diff --git a/backend/utils/http.js b/backend/utils/http.js new file mode 100644 index 0000000..f5e3e57 --- /dev/null +++ b/backend/utils/http.js @@ -0,0 +1,116 @@ +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +function errorCodeFromStatus(statusCode) { + switch (statusCode) { + case 400: + return "bad_request"; + case 401: + return "unauthorized"; + case 403: + return "forbidden"; + case 404: + return "not_found"; + case 409: + return "conflict"; + case 413: + return "payload_too_large"; + case 415: + return "unsupported_media_type"; + case 422: + return "unprocessable_entity"; + case 429: + return "rate_limited"; + case 500: + return "internal_error"; + default: + return statusCode >= 500 ? "internal_error" : "request_error"; + } +} + +function normalizeErrorPayload(payload, statusCode) { + if (statusCode < 400) return payload; + + if (typeof payload === "string") { + return { + error: { + code: errorCodeFromStatus(statusCode), + message: payload, + }, + }; + } + + if (!isPlainObject(payload)) { + return { + error: { + code: errorCodeFromStatus(statusCode), + message: "Request failed", + }, + }; + } + + if (isPlainObject(payload.error)) { + const code = payload.error.code || errorCodeFromStatus(statusCode); + const message = payload.error.message || "Request failed"; + return { + ...payload, + error: { + ...payload.error, + code, + message, + }, + }; + } + + if (typeof payload.error === "string") { + const { error, ...rest } = payload; + return { + ...rest, + error: { + code: errorCodeFromStatus(statusCode), + message: error, + }, + }; + } + + if (typeof payload.message === "string") { + const { message, ...rest } = payload; + return { + ...rest, + error: { + code: errorCodeFromStatus(statusCode), + message, + }, + }; + } + + return { + ...payload, + error: { + code: errorCodeFromStatus(statusCode), + message: "Request failed", + }, + }; +} + +function sendError(res, statusCode, message, code, extra = {}) { + return res.status(statusCode).json({ + ...extra, + error: { + code: code || errorCodeFromStatus(statusCode), + message, + }, + }); +} + +module.exports = { + errorCodeFromStatus, + normalizeErrorPayload, + sendError, +};