748 lines
23 KiB
JavaScript
748 lines
23 KiB
JavaScript
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, 1–10 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"
|
||
});
|
||
}
|
||
};
|