fcsc_ipi_backend/app/controllers/unitMasterController.js

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" });
}
};