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