const db = require("../models"); const { Op } = require("sequelize"); const { Sequelize } = require("sequelize"); const { UnitMaster } = require("../models"); const fs = require("fs"); const csv = require("csv-parser"); const path = require("path"); const sanitize = require("sanitize-html"); const logger = require("../services/logger"); const sanitizeStringValue = (value) => typeof value === "string" ? sanitize(value, { allowedTags: [], allowedAttributes: {} }).trim() : value; exports.createUnit = async (req, res) => { try { let { uom, description, is_base_unit, base_unit_id, factor } = req.body; uom = sanitizeStringValue(uom); description = sanitizeStringValue(description); factor = sanitizeStringValue(factor); if (!uom || typeof uom !== "string" || !uom.trim()) { return res.status(400).json({ status: "error", message: "UOM (Unit Name) is required and must be a non-empty string.", }); } if (!description) { return res.status(400).json({ status: "error", message: "Description is required and must be a non-empty string.", }); } const cleaned = uom.replace(/[^A-Za-z]/g, ""); if (!cleaned) { return res.status(400).json({ status: "error", message: "UOM must contain at least one alphabetic character.", }); } is_base_unit = Boolean(is_base_unit); if (base_unit_id !== null && base_unit_id !== undefined) { base_unit_id = Number(base_unit_id); if (Number.isNaN(base_unit_id)) { return res.status(400).json({ status: "error", message: "base_unit_id must be a valid number." }); } } const existingUOM = await UnitMaster.findOne({ where: Sequelize.where( Sequelize.fn("LOWER", Sequelize.col("uom")), cleaned.toLowerCase() ), }); if (existingUOM) { return res.status(400).json({ status: "error", message: `Unit '${cleaned}' already exists with short key '${existingUOM.uom_short_name}'.`, }); } // Abbreviation map const abbreviationMap = { METER: "MT", METRE: "MT", KILOGRAM: "KG", GRAM: "GM", LITER: "LTR", LITRE: "LTR", CENTIMETER: "CM", MILLIMETER: "MM", SECOND: "SEC", MINUTE: "MIN", HOUR: "HR", DAY: "DAY", PIECE: "PC", BOX: "BX", USER: "USR", ITEM: "ITM", UNIT: "UNT", }; let uomShort = cleaned.length <= 5 ? cleaned : (abbreviationMap[cleaned.toUpperCase()] || cleaned.substring(0, 3)) + Math.random().toString(36).substring(2, 4).toUpperCase(); uomShort = uomShort.substring(0, 5); let exists = await UnitMaster.findOne({ where: { uom_short_name: uomShort } }); while (exists) { const suffix = Math.random().toString(36).substring(2, 3).toUpperCase(); uomShort = (uomShort.substring(0, 4) + suffix).substring(0, 5); exists = await UnitMaster.findOne({ where: { uom_short_name: uomShort } }); } const unit = await UnitMaster.create({ uom: cleaned, uom_short_name: uomShort, description, is_base_unit, base_unit_id, factor: factor || null, created_by: req.body.created_by || req.user.id }); return res.status(201).json({ status: "success", message: "Unit created successfully." }); } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; // Get all Units exports.getAllUnits = async (req, res) => { try { const units = await UnitMaster.findAll({ attributes: { include: [ [ Sequelize.literal( `(SELECT COUNT(*) FROM products AS P WHERE P.unit_id = UnitMaster.id)` ), "mapped_products_count", ], [ Sequelize.literal( `(SELECT name FROM admin_users AU WHERE AU.id = UnitMaster.created_by)` ), "created_by_name" ], [ Sequelize.literal( `(SELECT name FROM admin_users AU WHERE AU.id = UnitMaster.updated_by)` ), "updated_by_name" ] ], }, }); res.status(200).json({ status: "success",unit_count : units.length, data: units }); } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; // Get Unit by ID exports.getUnitById = async (req, res) => { try { const unit = await UnitMaster.findByPk(req.params.id); if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" }); res.status(200).json({ status: "success", data: unit }); } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; // Update Unit exports.updateUnit = async (req, res) => { try { const unit = await UnitMaster.findByPk(req.params.id); if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" }); const payload = { ...req.body }; payload.updated_by = payload.updated_by || req.user.id; payload.updated_at = payload.updated_at || new Date(); if (payload.uom !== undefined) { payload.uom = sanitizeStringValue(payload.uom); } if (payload.description !== undefined) { payload.description = sanitizeStringValue(payload.description); } if (payload.factor !== undefined && typeof payload.factor === "string") { payload.factor = sanitizeStringValue(payload.factor); } await unit.update(payload); res.status(200).json({ status: "success" }); } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; // Delete Unit exports.deleteUnit = async (req, res) => { try { const unit = await UnitMaster.findByPk(req.params.id); if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" }); await unit.destroy(); res.status(200).json({ status: "success", message: "Unit deleted successfully" }); } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; const { UPLOAD_DIR } = require("../config/upload.config"); if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } 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); // Verify the resolved path is within allowed directory 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 false; } try { if (validateFileExists(filePath)) { fs.unlinkSync(filePath); return true; } return false; } catch (err) { logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); console.error("File delete error:", err.message); return false; } } exports.uploadUnitMasterFromCSV = 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) { 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. Security validation failed.", }); } // Validate file exists after sanitization if (!validateFileExists(sanitizedPath)) { return res.status(400).send({ status: "failed", message: "Uploaded file not found", }); } // Validate file extension using basename 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: "Only CSV files are 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 CSV is empty", }); } let rows = []; fs.createReadStream(sanitizedPath) .pipe(csv()) .on("data", (row) => { rows.push(row); }) .on("end", async () => { try { if (rows.length === 0) { deleteFileSecure(sanitizedPath); return res.status(400).send({ status: "failed", message: "CSV file is empty or invalid", }); } const normalizeHeader = (h) => { if (!h || typeof h !== 'string') return ''; return h .replace(/\*/g, "") .replace(/\(.*?\)/g, "") .trim() .replace(/[\s\W]+/g, "_") .toLowerCase(); }; const requiredCols = { unit_name: "Unit Name", description: "Description", }; const incomingHeaders = Object.keys(rows[0]); const normalizedHeaders = []; for (let i = 0; i < incomingHeaders.length; i++) { normalizedHeaders.push(normalizeHeader(incomingHeaders[i])); } const requiredKeys = Object.keys(requiredCols); const missing = []; for (let i = 0; i < requiredKeys.length; i++) { const col = requiredKeys[i]; if (!normalizedHeaders.includes(col)) { missing.push(col); } } const extra = []; for (let i = 0; i < normalizedHeaders.length; i++) { const col = normalizedHeaders[i]; if (!requiredKeys.includes(col)) { extra.push(col); } } if (missing.length > 0 || extra.length > 0) { deleteFileSecure(sanitizedPath); const errorParts = []; if (missing.length > 0) { const missingNames = []; for (let i = 0; i < missing.length; i++) { missingNames.push(requiredCols[missing[i]]); } errorParts.push(`Missing columns: ${missingNames.join(", ")}`); } if (extra.length > 0) { errorParts.push(`Unexpected columns: ${extra.join(", ")}`); } return res.status(400).send({ status: "failed", message: errorParts.join(". "), }); } // Remap headers const remappedRows = []; for (let i = 0; i < rows.length; i++) { const r = rows[i]; const obj = {}; for (const key in r) { if (!r.hasOwnProperty(key)) continue; const n = normalizeHeader(key); if (requiredCols[n]) { obj[requiredCols[n]] = r[key]; } } remappedRows.push(obj); } const inserted = []; const duplicates = []; const validationErrors = []; const seenInFile = new Set(); const generateShortName = (name) => { const base = name.replace(/[^A-Za-z]/g, "").slice(0, 3); const suffix = Math.random().toString(36).substring(2, 4).toUpperCase(); return `${base}${suffix}`; }; for (let i = 0; i < remappedRows.length; i++) { try { const row = remappedRows[i]; const rowNumber = i + 1; const uomValue = row["Unit Name"]; const uom = uomValue ? uomValue.trim() : ""; const descRaw = row["Description"]; const desc = (typeof descRaw === "string") ? descRaw.trim() : ""; // Validate required fields if (!uom) { validationErrors.push({ row: rowNumber, reason: "Unit Name missing" }); continue; } if (!desc) { validationErrors.push({ row: rowNumber, reason: "Description is required and cannot be empty", }); continue; } if (desc.length > 1000) { validationErrors.push({ row: rowNumber, reason: "Description is too long. Maximum allowed length is 1000 characters.", }); continue; } // Check for duplicates within file const uomLower = uom.toLowerCase(); if (seenInFile.has(uomLower)) { validationErrors.push({ row: rowNumber, reason: "Duplicate Unit Name in file" }); continue; } seenInFile.add(uomLower); const shortName = generateShortName(uom); // Check if exists in database const exists = await UnitMaster.findOne({ where: { [Op.or]: [ Sequelize.where( Sequelize.fn("LOWER", Sequelize.col("uom")), uom.toLowerCase() ), { uom_short_name: shortName }, ], }, }); if (exists) { duplicates.push({ row: rowNumber, uom: exists.uom, uom_short_name: exists.uom_short_name, }); continue; } // Create new unit const created = await UnitMaster.create({ uom: uom, uom_short_name: shortName, description: desc, created_by: req.user.id, created_at: new Date(), }); inserted.push(created); } catch (err) { validationErrors.push({ row: i + 1, reason: err.message }); } } // Clean up file after processing deleteFileSecure(sanitizedPath); // Determine response status let status = "failed"; let httpCode = 400; if (inserted.length > 0 && (duplicates.length > 0 || validationErrors.length > 0)) { status = "partial_success"; httpCode = 200; } else if (inserted.length > 0) { status = "success"; httpCode = 200; } const message = `${inserted.length} inserted, ${duplicates.length} duplicates, ${validationErrors.length} errors`; return res.status(httpCode).send({ status: status, message: message, summary: { total: remappedRows.length, inserted: inserted.length, duplicates: duplicates.length, errors: validationErrors.length, }, duplicates: duplicates, errors: validationErrors, }); } catch (err) { deleteFileSecure(sanitizedPath); return res.status(500).send({ status: "failed", message: err.message, }); } }) .on("error", (err) => { deleteFileSecure(sanitizedPath); return res.status(500).send({ status: "failed", message: "CSV read error", error: err.message, }); }); } catch (err) { deleteFileSecure(sanitizedPath); logger.error(err.message); logger.error(`Stack trace: ${err.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } }; exports.downloadUnitMasterFile = async (req, res) => { try { const safeBasePath = path.resolve(__dirname, "../downloads_csv"); const safeFilePath = path.join(safeBasePath, "unit_master_sample.csv"); // Verify file exists BEFORE sending if (!fs.existsSync(safeFilePath)) { return res.status(404).send({ status: "failed", message: "File not found", }); } return res.download(safeFilePath, "unit_master_sample.csv"); } catch (error) { logger.error(error.message); logger.error(`Stack trace: ${error.stack}`); res.status(500).send({ status: "failed", message: "Internal server error" }); } };