639 lines
18 KiB
JavaScript
639 lines
18 KiB
JavaScript
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" });
|
|
}
|
|
};
|