Spaces:
Runtime error
Runtime error
| 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" }); | |
| } | |
| } | |