inventory-pro / backend /src /controllers /products.controller.js
Amirm8950
MERN Inventory: working backend + frontend
8b8b6f5
import Product from "../models/Product.js";
import InventoryHistory from "../models/InventoryHistory.js";
import { parseCsvBuffer } from "../utils/csv.js";
import { Parser as Json2CsvParser } from "json2csv";
/* ---------- helpers ---------- */
const STATUS = { IN: "IN_STOCK", OUT: "OUT_OF_STOCK" };
const ALLOWED_SORT = new Set(["name", "category", "brand", "stock", "status", "createdAt"]);
const escapeRegex = (s = "") => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ciEq = (name) => ({ $regex: `^${escapeRegex(name)}$`, $options: "i" });
/* ---------- GET /api/products (and /search alias) ---------- */
export async function listProducts(req, res) {
try {
const page = Math.max(parseInt(req.query.page ?? "1", 10), 1);
const limit = Math.min(Math.max(parseInt(req.query.limit ?? "10", 10), 1), 100);
const q = {};
if (req.query.name) q.name = { $regex: req.query.name, $options: "i" };
if (req.query.category) q.category = req.query.category;
const sortBy = ALLOWED_SORT.has(req.query.sortBy) ? req.query.sortBy : "createdAt";
const order = req.query.order === "asc" ? 1 : -1;
const sort = { [sortBy]: order };
const [totalDocs, docs] = await Promise.all([
Product.countDocuments(q),
Product.find(q).sort(sort).skip((page - 1) * limit).limit(limit),
]);
res.json({
data: docs,
page,
totalDocs,
totalPages: Math.ceil(totalDocs / limit) || 1,
});
} catch (err) {
console.error("listProducts error:", err);
res.status(500).json({ error: "Failed to fetch products" });
}
}
/* ---------- POST /api/products/import ---------- */
export async function importProducts(req, res) {
try {
if (!req.file?.buffer) {
return res.status(400).json({ error: 'CSV file is required (field name: "file")' });
}
const rows = await parseCsvBuffer(req.file.buffer);
// Normalize + validate minimal required fields
const normalized = rows
.map((r) => ({
name: String(r.name ?? "").trim(),
unit: String(r.unit ?? "").trim(),
category: String(r.category ?? "").trim(),
brand: String(r.brand ?? "").trim(),
stock: Number.isFinite(Number(r.stock)) ? Number(r.stock) : 0,
status:
String(r.status ?? "").trim().toUpperCase() === STATUS.OUT
? STATUS.OUT
: STATUS.IN,
image: String(r.image ?? "").trim(),
}))
.filter((r) => r.name && r.unit && r.category);
if (!normalized.length) return res.status(400).json({ error: "No valid rows found in CSV" });
const toInsert = [];
const skipped = [];
for (const r of normalized) {
const existing = await Product.findOne({ name: ciEq(r.name) }).lean();
if (existing) {
skipped.push({
reason: "duplicate_name",
name: r.name,
existingId: String(existing._id),
});
continue;
}
// derive status from stock if not explicit
r.status = r.stock > 0 ? STATUS.IN : STATUS.OUT;
toInsert.push(r);
}
let inserted = [];
if (toInsert.length) {
inserted = await Product.insertMany(toInsert, { ordered: false });
}
res.json({
addedCount: inserted.length,
skipped,
});
} catch (err) {
console.error("importProducts error:", err);
res.status(500).json({ error: "Failed to import CSV" });
}
}
/* ---------- GET /api/products/export ---------- */
export async function exportProducts(_req, res) {
try {
const products = await Product.find().lean();
const fields = ["name", "unit", "category", "brand", "stock", "status", "image"];
const parser = new Json2CsvParser({ fields });
const csv = parser.parse(products);
res.header("Content-Type", "text/csv; charset=utf-8");
res.attachment("products.csv");
res.send(csv);
} catch (err) {
console.error("exportProducts error:", err);
res.status(500).json({ error: "Failed to export CSV" });
}
}
/* ---------- PUT /api/products/:id ---------- */
export async function updateProduct(req, res) {
try {
const { id } = req.params;
const patch = req.body ?? {};
const product = await Product.findById(id);
if (!product) return res.status(404).json({ error: "Product not found" });
// validate unique name if changed
if (patch.name && patch.name.trim() && patch.name.trim() !== product.name) {
const dup = await Product.findOne({ _id: { $ne: id }, name: ciEq(patch.name) }).lean();
if (dup) return res.status(400).json({ error: "Name must be unique" });
}
// validate stock
if (patch.stock !== undefined) {
const n = Number(patch.stock);
if (!Number.isFinite(n) || n < 0) return res.status(400).json({ error: "Stock must be a non-negative number" });
patch.stock = n;
}
// validate status
if (patch.status && ![STATUS.IN, STATUS.OUT].includes(patch.status)) {
return res.status(400).json({ error: "Invalid status" });
}
const oldStock = product.stock;
// apply patch
["name", "unit", "category", "brand", "stock", "status", "image"].forEach((k) => {
if (patch[k] !== undefined) product[k] = patch[k];
});
// auto-status from stock if status not explicitly provided
if (patch.stock !== undefined && patch.status === undefined) {
product.status = product.stock > 0 ? STATUS.IN : STATUS.OUT;
}
const saved = await product.save();
// log history if stock changed
if (patch.stock !== undefined && oldStock !== saved.stock) {
await InventoryHistory.create({
product: saved._id,
oldQty: oldStock ?? 0,
newQty: saved.stock ?? 0,
changedBy: req.user?.email || "system",
});
}
res.json(saved);
} catch (err) {
console.error("updateProduct error:", err);
res.status(500).json({ error: "Failed to update product" });
}
}
/* ---------- DELETE /api/products/:id ---------- */
export async function deleteProduct(req, res) {
try {
const { id } = req.params;
const found = await Product.findByIdAndDelete(id);
if (!found) return res.status(404).json({ error: "Product not found" });
// optional: also remove history
// await InventoryHistory.deleteMany({ product: id });
res.json({ ok: true });
} catch (err) {
console.error("deleteProduct error:", err);
res.status(500).json({ error: "Failed to delete product" });
}
}
/* ---------- GET /api/products/:id/history ---------- */
export async function getProductHistory(req, res) {
try {
const { id } = req.params;
const logs = await InventoryHistory.find({ product: id })
.sort({ createdAt: -1 })
.lean();
res.json({ data: logs });
} catch (err) {
console.error("getProductHistory error:", err);
res.status(500).json({ error: "Failed to fetch history" });
}
}