diff --git a/app/controllers/products.controller.js b/app/controllers/products.controller.js index c65d5ca..a84ea6c 100644 --- a/app/controllers/products.controller.js +++ b/app/controllers/products.controller.js @@ -14,19 +14,69 @@ const cleanString = (value) => ? sanitize(value, { allowedTags: [], allowedAttributes: {} }) : value; -/** Parses optional products.weight_in_ib (nullable integer, non-negative). */ +/** Matches products.weight_in_ib DECIMAL(18,10): max 8 digits before the decimal. */ +const WEIGHT_DECIMAL_PLACES = 10; +const WEIGHT_MAX_BEFORE_DECIMAL = 8; +const WEIGHT_MAX = + Number("9".repeat(WEIGHT_MAX_BEFORE_DECIMAL) + "." + "9".repeat(WEIGHT_DECIMAL_PLACES)); + +/** Parses optional products.weight_in_ib (nullable non-negative decimal). */ function parseWeightInIb(value) { if (value === undefined || value === null || value === "") { return { ok: true, value: null }; } - const n = parseInt(String(value).trim(), 10); - if (Number.isNaN(n)) { - return { ok: false, message: "weight_in_ib must be a valid integer." }; + const str = String(value).trim().replace(",", "."); + const n = Number(str); + if (!Number.isFinite(n)) { + return { ok: false, message: "weight_in_ib must be a valid number." }; } if (n < 0) { return { ok: false, message: "weight_in_ib must be non-negative." }; } - return { ok: true, value: n }; + const rounded = Number(n.toFixed(WEIGHT_DECIMAL_PLACES)); + if (rounded > WEIGHT_MAX) { + return { + ok: false, + message: `weight_in_ib is too large. Maximum is ${WEIGHT_MAX} (${WEIGHT_MAX_BEFORE_DECIMAL} digits before the decimal).`, + }; + } + return { ok: true, value: rounded }; +} + +/** Returns true if a response was sent (4xx). */ +function tryRespondProductPersistenceError(error, res) { + if (!error || res.headersSent) { + return false; + } + + if (error.name === "SequelizeUniqueConstraintError") { + const field = error.errors?.[0]?.path ?? "field"; + res.status(400).json({ + status: "failed", + message: `${field} already exists.`, + }); + return true; + } + + const sqlMessage = error.parent?.sqlMessage || error.original?.sqlMessage || ""; + const errno = error.parent?.errno ?? error.original?.errno; + const combined = `${error.message} ${sqlMessage}`; + + if (error.name === "SequelizeDatabaseError") { + if ( + combined.includes("weight_in_ib") || + (errno === 1264 && /weight/i.test(combined)) + ) { + res.status(400).json({ + status: "failed", + message: + "weight_in_ib is outside the range allowed by your database column (MySQL DECIMAL precision/scale). For example, DECIMAL(12,10) only allows values below 100. Either use a smaller weight, or widen the column (e.g. ALTER TABLE products MODIFY COLUMN weight_in_ib DECIMAL(18,10) NULL).", + }); + return true; + } + } + + return false; } exports.createProduct = async (req, res) => { @@ -95,6 +145,9 @@ exports.createProduct = async (req, res) => { } catch (error) { logger.error(error.message); logger.error(`Stack trace: ${error.stack}`); + if (tryRespondProductPersistenceError(error, res)) { + return; + } res.status(500).send({'status':"failed",'message': "Internal server error" }); } }; @@ -188,6 +241,9 @@ exports.updateProduct = async (req, res) => { } catch (error) { logger.error(error.message); logger.error(`Stack trace: ${error.stack}`); + if (tryRespondProductPersistenceError(error, res)) { + return; + } res.status(500).send({'status':"failed",'message': "Internal server error" }); } }; @@ -441,22 +497,15 @@ exports.uploadProductsFromCSV = async (req, res) => { row.weight_in_ib !== null && String(row.weight_in_ib).trim() !== "" ) { - const w = parseInt(String(row.weight_in_ib).trim(), 10); - if (Number.isNaN(w)) { + const wp = parseWeightInIb(row.weight_in_ib); + if (!wp.ok) { validationErrors.push({ row: rowNumber, - error: "weight_in_ib must be numeric", + error: wp.message, }); continue; } - if (w < 0) { - validationErrors.push({ - row: rowNumber, - error: "weight_in_ib must be non-negative", - }); - continue; - } - weightInIb = w; + weightInIb = wp.value; } // Validate required fields @@ -664,9 +713,12 @@ exports.uploadProductsFromCSV = async (req, res) => { logger.error("CSV processing error: " + processingError.message); logger.error(`Stack trace: ${processingError.stack}`); } - return res.status(500).send({ - status: "failed", - message: processingError.message + if (tryRespondProductPersistenceError(processingError, res)) { + return; + } + return res.status(500).send({ + status: "failed", + message: "Internal server error", }); } }) diff --git a/app/models/product.model.js b/app/models/product.model.js index f361973..fe7e863 100644 --- a/app/models/product.model.js +++ b/app/models/product.model.js @@ -39,7 +39,7 @@ module.exports = (sequelize, DataTypes) => { allowNull: true, }, weight_in_ib: { - type: DataTypes.INTEGER, + type: DataTypes.DECIMAL(18, 10), allowNull: true, }, is_active: { diff --git a/app/routes/routes.js b/app/routes/routes.js index f60942d..5e652e6 100644 --- a/app/routes/routes.js +++ b/app/routes/routes.js @@ -1281,9 +1281,9 @@ router.post("/trigger-establishment-users-welcome-email",[verifySignature, verif * type: integer * example: "select data from unit master" * weight_in_ib: - * type: integer - * description: Optional weight used in IPI weighting (integer). - * example: 100 + * type: number + * description: Optional weight used in IPI weighting (decimal, DECIMAL(18,10) in DB). + * example: 100.655214 * is_active: * type: boolean * example: true @@ -1368,9 +1368,9 @@ router.get("/products/:id", [verifySignature, verifyToken],productController.get * type: integer * example: "select data from unit master" * weight_in_ib: - * type: integer - * description: Optional weight used in IPI weighting (integer). Omit to leave unchanged; send null to clear. - * example: 100 + * type: number + * description: Optional weight used in IPI weighting (decimal). Omit to leave unchanged; send null to clear. + * example: 100.655214 * is_active: * type: boolean * example: true