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; 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; 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, 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}`); 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() }; 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}`); 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"; } 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 headers = Object.keys(csvResults[0]); const missingCols = requiredCols.filter(col => !headers.includes(col)); const extraCols = headers.filter(col => !requiredCols.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() : ""; // 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 }); } // 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, 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}`); } return res.status(500).send({ status: "failed", message: processingError.message }); } }) .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" }); } };