Compare commits

..

10 Commits

10 changed files with 752 additions and 406 deletions

View File

@ -2,7 +2,8 @@ const db = require("../models");
const logger = require("../services/logger");
const IPICalculationService = require("../services/ipi_calculation_service");
const SubmissionAutoFillService = require("../services/auto_fill_missing_quarterly_submissions_service");
const CalculationLog = db.CalculationLog;
const CalculationLog = db.CalculationLog;
const QuarterlyWindowsConfiguration = db.QuarterlyWindowsConfiguration;
// Database configuration
const dbConfig = {
@ -128,6 +129,20 @@ exports.calculate_month = async (req, res) => {
});
}
// check if the year and quarter window created or not and start the calculation
const window = await QuarterlyWindowsConfiguration.findOne({
where: {
year: year,
quarter: quarter
}
});
if (!window) {
return res.status(400).json({
success: false,
message: 'Year and quarter window not created'
});
}
const service = new IPICalculationService(dbConfig);
const result = await service.runCompleteCalculation(year, quarter);
await service.close();
@ -233,34 +248,103 @@ exports.calculation_log = async (req, res) => {
};
const validateYearQuarter = (year, quarter) => {
if (!year || !quarter) {
return { ok: false, message: "Year and quarter are required" };
}
if (!["Q1", "Q2", "Q3", "Q4"].includes(quarter)) {
return {
ok: false,
message: "Invalid quarter. Accepted values: Q1, Q2, Q3, Q4",
};
}
return { ok: true };
};
async function runSurveyAutoSubmit(year, quarter) {
const autoSubmissionService = new SubmissionAutoFillService(dbConfig);
const autoFillMissingSubmissions =
await autoSubmissionService.autoFillMissingSubmissions(year, quarter);
const verifyCompleteness =
await autoSubmissionService.verifyCompleteness(year, quarter);
return { autoFillMissingSubmissions, verifyCompleteness };
}
async function runQuarterCalculation(year, quarter) {
const service = new IPICalculationService(dbConfig);
try {
const data = await service.runCompleteCalculation(year, quarter);
return { message: `IPI calculated for ${year} ${quarter}`, data };
} finally {
await service.close();
}
}
// 5. Survey Auto Submit
exports.survey_auto_submit = async (req, res) => {
try {
const { year, quarter } = req.body;
const validation = validateYearQuarter(year, quarter);
if (!validation.ok) {
return res.status(400).json({ success: false, message: validation.message });
}
const autoSubmissionService = new SubmissionAutoFillService(dbConfig);
// Auto-fill missing submissions for specific year and quarter
const result = await autoSubmissionService.autoFillMissingSubmissions(year, quarter);
// Get report for specific quarter
// const result2 = await autoSubmissionService.getAutoFillReport(year, quarter);
// Verify completeness for specific quarter
const result3 = await autoSubmissionService.verifyCompleteness(year, quarter);
res.json({ success: true, autoFillMissingSubmissions: result , verifyCompleteness: result3 });
const result = await runSurveyAutoSubmit(year, quarter);
res.json({ success: true, ...result });
} catch (error) {
console.error('Error:', error);
console.error("Error:", error);
res.status(500).json({
success: false,
message: '',
error: error.message
message: "Error during survey auto submit",
error: error.message,
});
}
};
// 6. Auto submit missing surveys, then run quarterly IPI calculation
exports.survey_auto_submit_and_calculate = async (req, res) => {
try {
const { year, quarter } = req.body;
const validation = validateYearQuarter(year, quarter);
if (!validation.ok) {
return res.status(400).json({ success: false, message: validation.message });
}
// check if the year and quarter window created or not and start the calculation
const window = await QuarterlyWindowsConfiguration.findOne({
where: {
year: year,
quarter: quarter
}
});
if (!window) {
return res.status(400).json({
success: false,
message: 'Year and quarter window not created'
});
}
const autoSubmitResult = await runSurveyAutoSubmit(year, quarter);
const calculationResult = await runQuarterCalculation(year, quarter);
res.json({
success: true,
message: `Auto submit and IPI calculation completed for ${year} ${quarter}`,
year,
quarter,
steps: {
survey_auto_submit: autoSubmitResult,
calculate_quarter: calculationResult,
},
});
} catch (error) {
console.error("Error:", error);
res.status(500).json({
success: false,
message: "Error during auto submit and quarterly calculation",
error: error.message,
});
}
};

View File

@ -1,6 +1,7 @@
exports.deployment = async (req, res) => {
try {
const fs = require('fs');
@ -62,7 +63,6 @@ exports.deployment = async (req, res) => {
return res.status(401).json({ ok: false, message: 'Invalid API key' });
}
// ##############################################################
const payload = req.body;

View File

@ -1057,16 +1057,17 @@ exports.forgotPasswordRequestOTP = async (req, res) => {
{
// Validate inputs
if (!establishment_name || !establishment_code || !registered_email)
return res.status(400).json({ status: "failed", message: "All fields are required" });
// if (!establishment_name || !establishment_code || !registered_email)
// return res.status(400).json({ status: "failed", message: "All fields are required" });
// Find establishment and user
const establishment = await Establishment.findOne({ where: { establishment_code } });
if (!establishment)
return res.status(404).json({ status: "failed", message: "Establishment not found" });
// // Find establishment and user
// const establishment = await Establishment.findOne({ where: { establishment_code } });
// if (!establishment)
// return res.status(404).json({ status: "failed", message: "Establishment not found" });
const user = await EstablishmentUser.findOne({
where: { email: registered_email, establishment_id: establishment.id },
// where: { email: registered_email, establishment_id: establishment.id },
where: { email: registered_email },
});
if (!user)

View File

@ -227,7 +227,7 @@ exports.changeUserPassword = async (req, res) => {
}
// Find user
const user = await EstablishmentUser.findByPk(userId);
const user = await EstablishmentUser.scope("withSensitive").findByPk(userId);
if (!user) {
return res.status(404).send({
status: "failed",

View File

@ -43,6 +43,117 @@ function parseWeightInIb(value) {
return { ok: true, value: rounded };
}
/** Normalize unit text for case-insensitive matching (e.g. "numbers" → "numbers", " Numbers " → "numbers"). */
function normalizeUnitKey(value) {
if (value === null || value === undefined) return "";
const normalized = String(value).trim().replace(/\s+/g, " ").toLowerCase();
if (!normalized || normalized === "not defined") return "";
return normalized;
}
/** Map unit_master by uom (full unit name), case-insensitive. */
function buildUnitLookupMap(unitMasters) {
const unitMap = new Map();
for (const unit of unitMasters) {
const key = normalizeUnitKey(unit.uom);
if (key) {
unitMap.set(key, unit.id);
}
}
return unitMap;
}
function resolveUnitId(unitInput, unitMap) {
const key = normalizeUnitKey(unitInput);
if (!key) return null;
return unitMap.get(key) ?? null;
}
function normalizeCsvHeaderKey(key) {
if (!key) return "";
const withoutBom = String(key).replace(/^\uFEFF/, "");
return withoutBom
.replace(/\*/g, "")
.replace(/\(.*?\)/g, "")
.trim()
.replace(/[\s\W]+/g, "_")
.trim()
.toLowerCase();
}
function mapCsvRowKey(normalizedKey) {
if (/^hs.*code$/.test(normalizedKey) || normalizedKey === "hscode") {
return "hs_code";
}
if (/^product.*name$/.test(normalizedKey)) {
return "product_name";
}
if (
normalizedKey === "unit" ||
normalizedKey === "uom" ||
normalizedKey === "measurement_unit" ||
normalizedKey === "measure" ||
normalizedKey.endsWith("_unit")
) {
return "unit";
}
if (/^desc(ription)?$/.test(normalizedKey)) {
return "description";
}
if (
normalizedKey === "weight_in_ib" ||
normalizedKey === "weight_in_lb" ||
/^weight.*(ib|lb)/i.test(normalizedKey) ||
normalizedKey === "weight"
) {
return "weight_in_ib";
}
return normalizedKey;
}
function transformCsvRow(row) {
const cleanRow = {};
for (const key in row) {
if (!Object.prototype.hasOwnProperty.call(row, key)) continue;
const mappedKey = mapCsvRowKey(normalizeCsvHeaderKey(key));
const value =
row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== ""
? String(row[key]).trim()
: null;
if (cleanRow[mappedKey] && !value) continue;
cleanRow[mappedKey] = value;
}
return cleanRow;
}
function isBlankCsvRow(row) {
return (
!row.hs_code &&
!row.product_name &&
!row.unit &&
!row.description &&
(row.weight_in_ib === undefined ||
row.weight_in_ib === null ||
String(row.weight_in_ib).trim() === "")
);
}
function parseCsvFile(filePath) {
return new Promise((resolve, reject) => {
const results = [];
fs.createReadStream(filePath)
.pipe(csv())
.on("data", (row) => results.push(transformCsvRow(row)))
.on("end", () => resolve(results))
.on("error", reject);
});
}
function sendUploadResponse(res, statusCode, body) {
if (res.headersSent) return;
return res.status(statusCode).json(body);
}
/** Returns true if a response was sent (4xx). */
function tryRespondProductPersistenceError(error, res) {
if (!error || res.headersSent) {
@ -334,420 +445,380 @@ 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"
return sendUploadResponse(res, 400, {
status: "failed",
message:
'No CSV file attached. Use multipart form field "file" and select your .csv before submitting.',
hint:
'In Swagger: open Try it out → pick hs-codes-sample.csv under field "file" → Execute.',
expected_field: "file",
});
}
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
// ignore cleanup errors
}
return res.status(400).send({
return sendUploadResponse(res, 400, {
status: "failed",
message: "Invalid file path detected"
message: "Invalid file path detected",
});
}
// Validate file exists after sanitization
if (!validateFileExists(sanitizedPath)) {
return res.status(400).send({
return sendUploadResponse(res, 400, {
status: "failed",
message: "File not found after validation"
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)) {
if (fileExt !== ".csv") {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
return sendUploadResponse(res, 400, {
status: "failed",
message: "Invalid MIME type. Only CSV allowed."
message: "Invalid file type. Only CSV allowed.",
});
}
// Validate user ID
if (!req.user || !req.user.id || isNaN(req.user.id)) {
const allowedMimes = ["text/csv", "application/csv", "text/plain", "application/vnd.ms-excel"];
if (req.file.mimetype && !allowedMimes.includes(req.file.mimetype)) {
deleteFileSecure(sanitizedPath);
return res.status(400).send({
status: "failed",
message: "Invalid or missing User Id."
return sendUploadResponse(res, 400, {
status: "failed",
message: "Invalid MIME type. Only CSV allowed.",
});
}
if (!req.user || !req.user.id || Number.isNaN(Number(req.user.id))) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
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({
return sendUploadResponse(res, 400, {
status: "failed",
message: "Uploaded file is empty.",
});
}
const csvResults = [];
const userId = parseInt(req.user.id, 10);
const csvResults = await parseCsvFile(sanitizedPath);
// 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"
});
if (csvResults.length === 0) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
status: "failed",
message: "CSV file is empty or invalid.",
});
}
const requiredCols = ["hs_code", "product_name", "unit"];
const optionalCols = ["description", "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 sendUploadResponse(res, 400, {
status: "failed",
message:
(missingCols.length ? `Missing columns: ${missingCols.join(", ")}. ` : "") +
(extraCols.length ? `Unexpected columns: ${extraCols.join(", ")}.` : ""),
});
}
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 csvLineNumber = index + 2;
if (isBlankCsvRow(row)) {
continue;
}
const 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 = normalizeUnitKey(row.unit);
const description = row.description ? row.description.trim() : "";
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: csvLineNumber, error: wp.message });
continue;
}
weightInIb = wp.value;
}
if (!hsCode || !productName || !unit) {
const missing = [];
if (!hsCode) missing.push("HS Code");
if (!productName) missing.push("Product Name");
if (!unit) missing.push("Unit");
validationErrors.push({
row: csvLineNumber,
error: `Missing required field(s): ${missing.join(", ")}`,
});
continue;
}
if (!/^\d+$/.test(hsCode)) {
validationErrors.push({ row: csvLineNumber, error: "HS Code must be numeric" });
continue;
}
if (hsCode.length > 10) {
validationErrors.push({
row: csvLineNumber,
error: "HS Code must be max 10 digits",
});
continue;
}
if (hsCode === "0" || /^0+$/.test(hsCode)) {
validationErrors.push({
row: csvLineNumber,
error: "HS Code cannot be 0",
});
continue;
}
if (productName.length > 1000) {
validationErrors.push({
row: csvLineNumber,
error: "Product Name is too long. Maximum 1000 characters.",
});
continue;
}
if (description.length > 1000) {
validationErrors.push({
row: csvLineNumber,
error: "HS Description is too long. Maximum 1000 characters.",
});
continue;
}
const normalizedHs = hsCode.replace(/^0+/, "") || hsCode;
if (seenHsCodes.has(normalizedHs)) {
fileDuplicates.add(hsCode);
continue;
}
seenHsCodes.add(normalizedHs);
normalizedRows.push({
hsCode,
productName,
unit,
description,
weightInIb,
csvLineNumber,
});
}
if (normalizedRows.length === 0 && validationErrors.length === 0) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
status: "failed",
message:
"No product data rows found. Add data below the header row (remove blank rows from the template).",
});
}
if (fileDuplicates.size > 0) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
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"],
where: { is_active: true },
});
const unitMap = buildUnitLookupMap(unitMasters);
const existingProducts = await Product.findAll({
attributes: ["hs_code", "product_name"],
where: { is_active: true },
});
const existingHsCodeSet = new Set();
const existingProductNameSet = new Set();
for (const product of existingProducts) {
if (product.hs_code != null && String(product.hs_code).trim() !== "") {
existingHsCodeSet.add(String(product.hs_code).replace(/^0+/, "") || "0");
}
if (product.product_name) {
existingProductNameSet.add(product.product_name.trim().toLowerCase());
}
}
const duplicateHsCodes = [];
const duplicateProductNames = [];
for (let i = 0; i < normalizedRows.length; i++) {
const row = normalizedRows[i];
const normalizedHs = row.hsCode.replace(/^0+/, "") || row.hsCode;
const productNameLower = row.productName.trim().toLowerCase();
if (existingHsCodeSet.has(normalizedHs)) {
duplicateHsCodes.push({
row: row.csvLineNumber,
hs_code: row.hsCode,
product_name: row.productName,
});
}
if (existingProductNameSet.has(productNameLower)) {
duplicateProductNames.push({
row: row.csvLineNumber,
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 sendUploadResponse(res, 400, {
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];
const unitId = resolveUnitId(row.unit, unitMap);
if (!unitId) {
validationErrors.push({
row: row.csvLineNumber,
error: `Invalid unit "${row.unit}". Use the unit name (uom) from Unit Master (e.g. Numbers, Kilogram). Matching is case-insensitive.`,
});
continue;
}
toInsert.push({
hs_code: parseInt(row.hsCode, 10),
product_name: row.productName,
unit_id: unitId,
hs_description: row.description || null,
weight_in_ib: row.weightInIb,
created_by: userId,
created_at: new Date(),
is_active: true,
});
}
if (validationErrors.length > 0) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
status: "failed",
message: `Upload rejected: ${validationErrors.length} validation error(s) found. Please fix and try again.`,
errors: validationErrors,
});
}
if (toInsert.length === 0) {
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 400, {
status: "failed",
message: "No valid product rows to import.",
});
}
let insertedRecords = [];
try {
insertedRecords = await Product.bulkCreate(toInsert, {
validate: true,
individualHooks: false,
});
} catch (bulkError) {
deleteFileSecure(sanitizedPath);
logger.error("Product bulkCreate error: " + bulkError.message);
logger.error(`Stack trace: ${bulkError.stack}`);
if (tryRespondProductPersistenceError(bulkError, res)) {
return;
}
throw bulkError;
}
deleteFileSecure(sanitizedPath);
return sendUploadResponse(res, 201, {
status: "success",
message: `${insertedRecords.length} product(s) inserted successfully.`,
summary: {
total_records: csvResults.length,
imported: insertedRecords.length,
skipped: csvResults.length - insertedRecords.length,
errors: [],
},
});
} catch (error) {
deleteFileSecure(sanitizedPath);
if (logger && logger.error) {
logger.error("Upload error: " + error.message);
logger.error(`Stack trace: ${error.stack}`);
logger.error("Upload error: " + error.message);
logger.error(`Stack trace: ${error.stack}`);
if (tryRespondProductPersistenceError(error, res)) {
return;
}
return res.status(500).send({
status: "failed",
message: "Internal server error"
return sendUploadResponse(res, 500, {
status: "failed",
message: "Internal server error",
});
}
};

View File

@ -137,7 +137,7 @@ exports.changeAdminUserPassword = async (req, res) => {
}
// Find user
const user = await User.findByPk(userId);
const user = await User.scope("withSensitive").findByPk(userId);
if (!user) {
return res.status(404).send({ status: "failed", message: "User not found" });
}

View File

@ -2,10 +2,23 @@ require("dotenv").config();
module.exports = function (req, res, next) {
const appSignature = req.headers["app_signature"];
const appSignature = (
req.headers["app_signature"] || req.headers["x-app-signature"] || ""
).trim();
const expectedSignature = (process.env.APP_SIGNATURE || "").trim();
if (!appSignature || appSignature !== process.env.APP_SIGNATURE) {
return res.status(403).json({ message: "Access denied, invalid or missing APP_SIGNATURE" });
if (!appSignature) {
return res.status(403).json({
status: "failed",
message: "Missing APP_SIGNATURE header.",
});
}
if (appSignature !== expectedSignature) {
return res.status(403).json({
status: "failed",
message: "Invalid APP_SIGNATURE. Use the exact value from server .env.",
});
}
next(); // allow request to continue

View File

@ -0,0 +1,116 @@
const multer = require("multer");
const { UPLOAD_DIR } = require("../config/upload.config");
const CSV_UPLOAD_FIELD = "file";
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 10 * 1024 * 1024 },
});
function respondCsvUploadError(res, statusCode, message, extra = {}) {
return res.status(statusCode).json({
status: "failed",
message,
...extra,
});
}
/**
* Parses multipart upload and sets req.file from form field "file".
* Returns helpful errors when Swagger/Postman sends the wrong shape.
*/
function handleCsvUpload(req, res, next) {
upload.any()(req, res, (err) => {
if (err) {
if (err.code === "LIMIT_FILE_SIZE") {
return respondCsvUploadError(
res,
400,
"CSV file is too large. Maximum allowed size is 10 MB."
);
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
return respondCsvUploadError(
res,
400,
`Unexpected file field "${err.field}". Use form field name "${CSV_UPLOAD_FIELD}" for the CSV.`,
{ expected_field: CSV_UPLOAD_FIELD, received_field: err.field }
);
}
return respondCsvUploadError(
res,
400,
"Could not read uploaded file. Please upload a valid .csv file.",
{ detail: err.message }
);
}
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
const contentType = req.headers["content-type"] || "";
if (!contentType.includes("multipart/form-data")) {
return respondCsvUploadError(
res,
400,
"CSV upload must use multipart/form-data, not JSON.",
{
hint: `Send your .csv in form field "${CSV_UPLOAD_FIELD}". In Swagger: choose the file under "${CSV_UPLOAD_FIELD}" before Execute.`,
expected_field: CSV_UPLOAD_FIELD,
received_content_type: contentType || "(missing)",
}
);
}
return respondCsvUploadError(
res,
400,
`No CSV file received. Attach a .csv file to form field "${CSV_UPLOAD_FIELD}".`,
{
hint:
'In Swagger UI: click "Try it out" → use the file picker for "' +
CSV_UPLOAD_FIELD +
'" (e.g. hs-codes-sample.csv) → then Execute. Do not leave the file field empty.',
expected_field: CSV_UPLOAD_FIELD,
example_curl:
'curl -F "file=@hs-codes-sample.csv;type=text/csv" ...',
}
);
}
const csvFile = files.find((f) => f.fieldname === CSV_UPLOAD_FIELD);
if (!csvFile) {
const receivedFields = [...new Set(files.map((f) => f.fieldname))];
return respondCsvUploadError(
res,
400,
`CSV must use form field "${CSV_UPLOAD_FIELD}", but received: ${receivedFields.join(", ")}.`,
{
hint: `Change the form field name to "${CSV_UPLOAD_FIELD}" and upload again.`,
expected_field: CSV_UPLOAD_FIELD,
received_fields: receivedFields,
}
);
}
if (files.length > 1) {
return respondCsvUploadError(
res,
400,
"Only one CSV file can be uploaded per request.",
{
hint: `Send a single file in field "${CSV_UPLOAD_FIELD}".`,
received_file_count: files.length,
}
);
}
req.file = csvFile;
return next();
});
}
module.exports = { handleCsvUpload, CSV_UPLOAD_FIELD };

View File

@ -27,6 +27,7 @@ const multer = require("multer");
const { UPLOAD_DIR } = require("../config/upload.config");
const upload = multer({ dest: UPLOAD_DIR });
const { handleCsvUpload } = require("../middleware/csvUpload.middleware");
@ -133,6 +134,49 @@ router.post("/survey_auto_submit",[] , calculationController.survey_auto_submit)
router.post("/calculate_quarter",[] , calculationController.calculate_quarter);
/**
* @swagger
* /api/survey_auto_submit_and_calculate:
* post:
* summary: Auto submit missing surveys, then calculate IPI for the quarter
* description: |
* Runs in sequence:
* 1. survey_auto_submit auto-fill missing submissions and verify completeness
* 2. calculate_quarter run IPI calculation for the same year and quarter
* tags: [IIP Calculation]
* security:
* - appSignature: []
* - CSRF: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - year
* - quarter
* properties:
* year:
* type: integer
* example: 2025
* quarter:
* type: string
* enum: [Q1, Q2, Q3, Q4]
* example: Q2
* responses:
* 200:
* description: Auto submit and quarterly calculation completed successfully
* 400:
* description: Invalid year or quarter
* 500:
* description: Server error
*/
router.post(
"/survey_auto_submit_and_calculate",
[],
calculationController.survey_auto_submit_and_calculate
);
router.get("/calculate_month",[] , calculationController.calculate_month);
@ -1415,29 +1459,36 @@ router.delete("/products/:id",[verifySignature, verifyToken], productController.
* description: This API accepts CSV file and inserts multiple products in bulk. CSV header columns must match Product table columns.
* tags: [Products]
* security:
* - appSignature: []
* - CSRF: []
* cookieAuth: [] # or bearerAuth: [] if you use Authorization header
* - bearerAuth: []
* appSignature: []
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required:
* - file
* properties:
* file:
* type: string
* format: binary
* description: CSV file to upload
* description: CSV file form field name must be "file"
* responses:
* 201:
* description: Products uploaded successfully
* 400:
* description: No file uploaded
* description: Missing file, wrong form field, or validation error
* 403:
* description: Missing or invalid APP_SIGNATURE or auth token
* 500:
* description: Server error
*/
router.post("/products/uploadCSV",[ verifySignature, verifyToken, upload.single("file")], productController.uploadProductsFromCSV);
router.post(
"/products/uploadCSV",
[verifySignature, verifyToken, handleCsvUpload],
productController.uploadProductsFromCSV
);
/**

View File

@ -251,7 +251,10 @@ app.use((req, res, next) => {
"/api/forgot-password/verify-otp",
"/api/csrf-token",
"/api/auth/request-otp",
"/api/auth/verify-otp"
"/api/auth/verify-otp",
"/api/products/uploadCSV",
"/api/establishments/uploadCSV",
"/api/unit_master/uploadCSV",
];
if (csrfExcludedPaths.includes(req.path)) {
@ -281,6 +284,13 @@ app.use((err, req, res, next) => {
message: "Invalid or missing CSRF token",
});
}
if (err.name === "MulterError") {
return res.status(400).json({
status: "failed",
message: err.message,
hint: 'Upload the CSV using form field "file".',
});
}
next(err);
});