fcsc_ipi_backend/app/controllers/products.controller.js

748 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require("fs");
const csv = require("csv-parser");
const path = require("path");
const db = require("../models");
const { Sequelize } = require("sequelize");
const Product = db.Product;
const UnitMaster = db.UnitMaster;
const sanitize = require("sanitize-html");
const { UPLOAD_DIR } = require('../config/upload.config');
const logger = require("../services/logger");
const cleanString = (value) =>
typeof value === "string"
? sanitize(value, { allowedTags: [], allowedAttributes: {} })
: value;
/** 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 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." };
}
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) => {
try {
const product_name = cleanString(req.body.product_name);
const hs_description = cleanString(req.body.hs_description);
const hs_code = req.body.hs_code;
const unit_id = req.body.unit_id;
const weightParsed = parseWeightInIb(req.body.weight_in_ib);
if (!weightParsed.ok) {
return res.status(400).json({ status: "failed", message: weightParsed.message });
}
if (hs_description && hs_description.length > 1000) {
return res.status(400).json({
status: "failed",
message: "HS Description is too long. Maximum allowed length is 1000 characters."
});
}
if (!/^\d+$/.test(hs_code)) {
return res.status(400).json({error: "Invalid HS Code: Must be numeric only."});
}
if (String(hs_code).length > 10 && String(hs_code).length < 1 && hs_code === 0 ) {
return res.status(400).send({status: "failed", message: "Invalid HS Code: maximum length is 10 digits."});
}
if(hs_code === 0 || String(hs_code) === "0"){
return res.status(400).send({status: "failed", message: " must be numeric, 110 digits, and cannot be 0."});
}
const productNameExists = await Product.findOne({
where: { product_name, is_active: true }
});
if (productNameExists) {
return res.status(400).json({
status: "failed",
message: `Product name '${product_name}' already exists.`
});
}
const hsCodeExists = await Product.findOne({
where: { hs_code, is_active: true }
});
if (hsCodeExists) {
return res.status(400).json({
status: "failed",
message: `HS Code '${hs_code}' already exists.`
});
}
await Product.create({
product_name,
hs_code,
hs_description,
unit_id,
weight_in_ib: weightParsed.value,
created_by: req.user.id
});
res.status(201).send({'status':"success",'message':"created successfully." });
} 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" });
}
};
exports.getAllProducts = async (req, res) => {
try {
const data = await Product.findAll({
where: {
is_active: true
},
attributes: {
include: [
[
Sequelize.literal(
`(SELECT COUNT(*) FROM establishment_products AS EP WHERE EP.product_id = products.id)`
),
"mapped_establishment_count",
],
[
Sequelize.literal(`(SELECT name FROM admin_users au WHERE au.id = products.created_by)`),
"created_by_name"
]
],
},
include: [
{
model: UnitMaster,
as: "unit",
attributes: ["uom"],
},
],
order: [
["product_name", "ASC"]
],
});
res.status(200).send({
status: "success",
message: "Fetched successfully",
product_count: data.length,
data: data
});
} catch (error) {
logger.error(error.message);
logger.error(`Stack trace: ${error.stack}`);
res.status(500).send({ status: "failed", message: "Internal server error" });
}
};
exports.getProductById = async (req, res) => {
try {
const data = await Product.findByPk(req.params.id);
if (!data) res.status(404).send({'status':"failed",'message':"Record Not found",'data': "" });
res.status(200).send({'status':"success",'message':"Fetched successfully",'data': data });
} catch (error) {
logger.error(error.message);
logger.error(`Stack trace: ${error.stack}`);
res.status(500).send({'status':"failed",'message': "Internal server error" });
}
};
exports.updateProduct = async (req, res) => {
try {
const object = {
...req.body,
updated_by: req.user.id,
updated_at: new Date()
};
if (Object.prototype.hasOwnProperty.call(req.body, "weight_in_ib")) {
const weightParsed = parseWeightInIb(req.body.weight_in_ib);
if (!weightParsed.ok) {
return res.status(400).json({ status: "failed", message: weightParsed.message });
}
object.weight_in_ib = weightParsed.value;
}
const [updated] = await Product.update(object, { where: { id: req.params.id } });
if (!updated) res.status(404).send({'status':"failed",'message':"Record Not found",'data': "" });
res.status(200).send({'status':"success",'message':"Updated successfully",'data': "" });
} 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" });
}
};
exports.deleteProduct = async (req, res) => {
try {
const deleted = await Product.destroy({ where: { id: req.params.id } });
if (!deleted) res.status(404).send({'status':"failed",'message':"Record Not found",'data': "" });
res.status(200).send({'status':"success",'message':"Deleted successfully",'data': "" });
} catch (error) {
logger.error(error.message);
logger.error(`Stack trace: ${error.stack}`);
res.status(500).send({'status':"failed",'message': "Internal server error" });
}
};
exports.downloadProductSample = async (req, res) => {
try {
const filePath = path.join(__dirname, "../downloads_csv/products_upload_sample.csv");
return res.download(filePath, "products_upload_sample.csv");
} catch (error) {
logger.error(error.message);
logger.error(`Stack trace: ${error.stack}`);
res.status(500).send({'status':"failed",'message': "Internal server error" });
}
};
function sanitizeFilePath(userInput, allowedDirectory) {
if (!userInput || typeof userInput !== 'string') {
throw new Error('Invalid file path input');
}
let sanitized = userInput.replace(/\.\./g, '');
sanitized = sanitized.replace(/[\/\\]+/g, path.sep);
const filename = path.basename(sanitized);
const fullPath = path.join(allowedDirectory, filename);
const resolvedPath = path.resolve(fullPath);
const resolvedBase = path.resolve(allowedDirectory);
if (!resolvedPath.startsWith(resolvedBase)) {
throw new Error('Path traversal attempt detected');
}
return resolvedPath;
}
function validateFileExists(filePath) {
if (!filePath) {
return false;
}
try {
return fs.existsSync(filePath);
} catch (err) {
return false;
}
}
function deleteFileSecure(filePath) {
if (!filePath) {
return;
}
try {
if (validateFileExists(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
if (logger && logger.error) {
logger.error('File deletion error: ' + err.message);
logger.error(`Stack trace: ${err.stack}`);
}
}
}
exports.uploadProductsFromCSV = async (req, res) => {
let sanitizedPath = null;
try {
// Validate file upload
if (!req.file) {
return res.status(400).send({
status: "failed",
message: "No file uploaded"
});
}
try {
sanitizedPath = sanitizeFilePath(req.file.path, UPLOAD_DIR);
} catch (sanitizeError) {
// Attempt cleanup with original path if sanitization fails
try {
const unsafePath = req.file.path;
if (unsafePath && fs.existsSync(unsafePath)) {
fs.unlinkSync(unsafePath);
}
} catch (cleanupErr) {
// Silent fail on cleanup
}
return res.status(400).send({
status: "failed",
message: "Invalid file path detected"
});
}
// Validate file exists after sanitization
if (!validateFileExists(sanitizedPath)) {
return res.status(400).send({
status: "failed",
message: "File not found after validation"
});
}
const originalName = path.basename(req.file.originalname);
const fileExt = path.extname(originalName).toLowerCase();
if (fileExt !== '.csv') {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Invalid file type. Only CSV allowed."
});
}
// Validate MIME type
const allowedMimes = ['text/csv', 'application/csv', 'text/plain'];
if (req.file.mimetype && !allowedMimes.some(mime => mime === req.file.mimetype)) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Invalid MIME type. Only CSV allowed."
});
}
// Validate user ID
if (!req.user || !req.user.id || isNaN(req.user.id)) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Invalid or missing User Id."
});
}
// Check file size
const fileStats = fs.statSync(sanitizedPath);
if (fileStats.size === 0) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Uploaded file is empty.",
});
}
const csvResults = [];
const userId = parseInt(req.user.id, 10);
// Process CSV using sanitized path
fs.createReadStream(sanitizedPath)
.pipe(csv())
.on("data", (row) => {
const cleanRow = {};
for (const key in row) {
if (!row.hasOwnProperty(key)) continue;
let cleanedHeader = key
.replace(/\*/g, "")
.replace(/\(.*?\)/g, "")
.trim();
const normalizedKey = cleanedHeader
.replace(/[\s\W]+/g, "_")
.trim()
.toLowerCase();
let mappedKey = normalizedKey;
if (/^hs.*code$/.test(normalizedKey) || normalizedKey === "hscode") {
mappedKey = "hs_code";
} else if (/^product.*name$/.test(normalizedKey)) {
mappedKey = "product_name";
} else if (/unit|measurement|uom|measure/i.test(normalizedKey)) {
mappedKey = "unit";
} else if (/desc(ription)?/i.test(normalizedKey)) {
mappedKey = "description";
} else if (
normalizedKey === "weight_in_ib" ||
/^weight.*(ib|lb)/i.test(normalizedKey) ||
/^weight$/i.test(normalizedKey)
) {
mappedKey = "weight_in_ib";
}
cleanRow[mappedKey] = row[key] ? row[key].trim() : null;
}
csvResults.push(cleanRow);
})
.on("end", async () => {
try {
// Validate CSV has data
if (csvResults.length === 0) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "CSV file is empty or invalid."
});
}
// Validate required columns
const requiredCols = ["hs_code", "product_name", "unit", "description"];
const optionalCols = ["weight_in_ib"];
const allowedCols = requiredCols.concat(optionalCols);
const headers = Object.keys(csvResults[0]);
const missingCols = requiredCols.filter(col => !headers.includes(col));
const extraCols = headers.filter(col => !allowedCols.includes(col));
if (missingCols.length > 0 || extraCols.length > 0) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message:
(missingCols.length ? `Missing columns: ${missingCols.join(", ")}. ` : "") +
(extraCols.length ? `Unexpected columns: ${extraCols.join(", ")}.` : "")
});
}
// Process and validate rows
const normalizedRows = [];
const seenHsCodes = new Set();
const fileDuplicates = new Set();
const validationErrors = [];
for (let index = 0; index < csvResults.length; index++) {
const row = csvResults[index];
const rowNumber = index + 1;
let hsCode = row.hs_code ? row.hs_code.replace(/[-\s/]/g, "").trim() : "";
const productName = row.product_name ? row.product_name.replace(/\s+/g, " ").trim() : "";
const unit = row.unit ? row.unit.trim().toLowerCase() : "";
const description = row.description ? row.description.trim().toLowerCase() : "";
let weightInIb = null;
if (
row.weight_in_ib !== undefined &&
row.weight_in_ib !== null &&
String(row.weight_in_ib).trim() !== ""
) {
const wp = parseWeightInIb(row.weight_in_ib);
if (!wp.ok) {
validationErrors.push({
row: rowNumber,
error: wp.message,
});
continue;
}
weightInIb = wp.value;
}
// Validate required fields
if (!hsCode || !productName) {
validationErrors.push({
row: rowNumber,
error: "Missing required HS Code or Product Name"
});
continue;
}
// Validate HS code format
if (!/^\d+$/.test(hsCode)) {
validationErrors.push({
row: rowNumber,
error: "HS Code must be numeric"
});
continue;
}
// Validate HS code length
if (hsCode.length > 10) {
validationErrors.push({
row: rowNumber,
error: "HS Code must be max 10 digits"
});
continue;
}
// Validate product name length
if (productName.length > 1000) {
validationErrors.push({
row: rowNumber,
error: "Product Name is too long. Maximum 1000 characters."
});
continue;
}
// Validate description length
if (description.length > 1000) {
validationErrors.push({
row: rowNumber,
error: "HS Description is too long. Maximum 1000 characters."
});
continue;
}
// Check for duplicates in file
const normalizedHs = hsCode.replace(/^0+/, "");
if (seenHsCodes.has(normalizedHs)) {
fileDuplicates.add(hsCode);
continue;
}
seenHsCodes.add(normalizedHs);
normalizedRows.push({
hsCode: hsCode,
productName: productName,
unit: unit,
description: description,
weightInIb,
});
}
// Check for file duplicates
if (fileDuplicates.size > 0) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Duplicate HS Codes found within file. Resolve and re-upload.",
duplicate_hs_codes_in_file: Array.from(fileDuplicates)
});
}
const unitMasters = await UnitMaster.findAll({
attributes: ["id", "uom"]
});
const unitMap = new Map();
for (let i = 0; i < unitMasters.length; i++) {
const unit = unitMasters[i];
const key = unit.uom.trim().toLowerCase();
unitMap.set(key, unit.id);
}
const existingProducts = await Product.findAll({
attributes: ["hs_code", "product_name"]
});
const existingHsCodeSet = new Set();
const existingProductNameSet = new Set();
for (let i = 0; i < existingProducts.length; i++) {
const normalized = existingProducts[i].hs_code.replace(/^0+/, "");
existingHsCodeSet.add(normalized);
const productNameLower = existingProducts[i].product_name.trim().toLowerCase();
existingProductNameSet.add(productNameLower);
}
const duplicateHsCodes = [];
const duplicateProductNames = [];
for (let i = 0; i < normalizedRows.length; i++) {
const row = normalizedRows[i];
const normalizedHs = row.hsCode.replace(/^0+/, "");
const productNameLower = row.productName.trim().toLowerCase();
if (existingHsCodeSet.has(normalizedHs)) {
duplicateHsCodes.push({
row: i + 1,
hs_code: row.hsCode,
product_name: row.productName
});
}
if (existingProductNameSet.has(productNameLower)) {
duplicateProductNames.push({
row: i + 1,
hs_code: row.hsCode,
product_name: row.productName
});
}
}
if (duplicateHsCodes.length > 0 || duplicateProductNames.length > 0) {
deleteFileSecure(sanitizedPath);
const errorMessages = [];
if (duplicateHsCodes.length > 0) {
errorMessages.push(`${duplicateHsCodes.length} duplicate HS Code(s) found in database`);
}
if (duplicateProductNames.length > 0) {
errorMessages.push(`${duplicateProductNames.length} duplicate Product Name(s) found in database`);
}
return res.status(400).send({
status: "failed",
message: "Upload rejected: " + errorMessages.join(", ") + ". Please remove duplicates and try again.",
duplicate_hs_codes: duplicateHsCodes,
duplicate_product_names: duplicateProductNames,
total_duplicates: duplicateHsCodes.length + duplicateProductNames.length
});
}
const toInsert = [];
for (let i = 0; i < normalizedRows.length; i++) {
const row = normalizedRows[i];
// Validate unit
const unitId = unitMap.get(row.unit);
if (!unitId) {
validationErrors.push({
row: i + 1,
error: "Invalid unit: " + row.unit
});
continue;
}
toInsert.push({
hs_code: row.hsCode,
product_name: row.productName,
unit_id: unitId,
hs_description: row.description,
weight_in_ib: row.weightInIb,
created_by: userId,
created_at: new Date(),
});
}
if (validationErrors.length > 0) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: `Upload rejected: ${validationErrors.length} validation error(s) found. Please fix and try again.`,
errors: validationErrors
});
}
let insertedRecords = [];
if (toInsert.length > 0) {
insertedRecords = await Product.bulkCreate(toInsert, {
validate: true
});
}
deleteFileSecure(sanitizedPath);
return res.status(200).send({
status: "success",
message: `${insertedRecords.length} products inserted successfully.`,
summary: {
total_records: csvResults.length,
imported: insertedRecords.length,
skipped: 0,
errors: []
}
});
} catch (processingError) {
deleteFileSecure(sanitizedPath);
if (logger && logger.error) {
logger.error("CSV processing error: " + processingError.message);
logger.error(`Stack trace: ${processingError.stack}`);
}
if (tryRespondProductPersistenceError(processingError, res)) {
return;
}
return res.status(500).send({
status: "failed",
message: "Internal server error",
});
}
})
.on("error", (streamError) => {
deleteFileSecure(sanitizedPath);
if (logger && logger.error) {
logger.error("Stream error: " + streamError.message);
}
return res.status(500).send({
status: "failed",
message: "Error reading CSV file"
});
});
} catch (error) {
deleteFileSecure(sanitizedPath);
if (logger && logger.error) {
logger.error("Upload error: " + error.message);
logger.error(`Stack trace: ${error.stack}`);
}
return res.status(500).send({
status: "failed",
message: "Internal server error"
});
}
};