diff --git a/src/controllers/kitchenandinventory/KIInventoryController.js b/src/controllers/kitchenandinventory/KIInventoryController.js index ad79dac1c..5c3070d6c 100644 --- a/src/controllers/kitchenandinventory/KIInventoryController.js +++ b/src/controllers/kitchenandinventory/KIInventoryController.js @@ -48,6 +48,7 @@ const KIInventoryController = () => { const items = await KIInventoryItem.find(null, { __v: 0 }).lean().sort({ createdAt: -1 }); res.status(200).json({ message: 'All Items fetched successfully.', data: items }); } catch (err) { + console.error(err); res.status(400).json({ message: 'Something went wrong while fetching items.' }); } }; @@ -59,6 +60,7 @@ const KIInventoryController = () => { .sort({ createdAt: -1 }); res.status(200).json({ message: 'Items fetched successfully.', data: items }); } catch (err) { + console.error(err); res.status(400).json({ message: 'Something went wrong while fetching items.' }); } }; @@ -74,6 +76,7 @@ const KIInventoryController = () => { .sort({ presentQuantity: -1 }); res.status(200).json({ message: 'Preserved stock items fetched successfully.', data: items }); } catch (err) { + console.error(err); res .status(400) .json({ message: 'Something went wrong while fetching preserved stock items.' }); @@ -154,6 +157,39 @@ const KIInventoryController = () => { res.status(400).json({ message: err.message }); } }; + const getInventoryStats = async (req, res) => { + try { + // Only fetch the fields needed for stats — avoids sending full item payloads + const items = await KIInventoryItem.find( + {}, + { presentQuantity: 1, reorderAt: 1, category: 1, expiryDate: 1 }, + ).lean(); + const totalItems = items.length; + const criticalStock = items.filter((i) => i.presentQuantity <= i.reorderAt * 0.5).length; + const lowStock = items.filter( + (i) => i.presentQuantity <= i.reorderAt && i.presentQuantity > i.reorderAt * 0.5, + ).length; + + const oneYearFromNow = new Date(); + oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); + const preserved = items.filter( + (i) => i.category === 'INGREDIENT' && new Date(i.expiryDate) >= oneYearFromNow, + ).length; + + res.status(200).json({ + message: 'Inventory stats fetched successfully.', + data: { + totalItems, + criticalStock, + lowStock, + preserved, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: 'Something went wrong while fetching inventory stats.' }); + } + }; return { addItem, getItems, @@ -162,6 +198,7 @@ const KIInventoryController = () => { updateOnUsage, updateStoredQuantity, updateNextHarvest, + getInventoryStats, }; }; module.exports = KIInventoryController; diff --git a/src/controllers/kitchenandinventory/KIInventoryController.spec.js b/src/controllers/kitchenandinventory/KIInventoryController.spec.js new file mode 100644 index 000000000..99058e5e5 --- /dev/null +++ b/src/controllers/kitchenandinventory/KIInventoryController.spec.js @@ -0,0 +1,497 @@ +jest.mock('../../models/kitchenandinventory/KIInventoryItems', () => { + const mock = jest.fn(); + mock.find = jest.fn(); + mock.findById = jest.fn(); + return mock; +}); + +const KIInventoryItem = require('../../models/kitchenandinventory/KIInventoryItems'); +const KIInventoryController = require('./KIInventoryController'); + +const VALID_OID = '507f1f77bcf86cd799439011'; + +function makeReq(overrides = {}) { + return { + body: overrides.body || {}, + query: overrides.query || {}, + params: overrides.params || {}, + }; +} + +function makeRes() { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res; +} + +describe('KIInventoryController', () => { + let controller; + + beforeEach(() => { + jest.clearAllMocks(); + controller = KIInventoryController(); + }); + + describe('addItem', () => { + test('successfully adds a new item', async () => { + const itemData = { + name: 'Apples', + storedQuantity: 10, + unit: 'lbs', + type: 'fruit', + monthlyUsage: 5, + category: 'INGREDIENT', + expiryDate: '2030-01-01', + }; + const req = makeReq({ body: itemData }); + const res = makeRes(); + + const saveMock = jest.fn().mockResolvedValue(itemData); + KIInventoryItem.mockImplementation(() => ({ + save: saveMock, + })); + + await controller.addItem(req, res); + + expect(saveMock).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ + message: 'Inventory item added successfully', + data: expect.any(Object), + }); + }); + + test('returns 400 when save fails', async () => { + const req = makeReq({ body: {} }); + const res = makeRes(); + + KIInventoryItem.mockImplementation(() => ({ + save: jest.fn().mockRejectedValue(new Error('Save error')), + })); + + await controller.addItem(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Save error' }); + }); + }); + + describe('getItems', () => { + test('successfully fetches all items', async () => { + const mockItems = [{ name: 'Item 1' }, { name: 'Item 2' }]; + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockResolvedValue(mockItems), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getItems(req, res); + + expect(KIInventoryItem.find).toHaveBeenCalledWith(null, { __v: 0 }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'All Items fetched successfully.', + data: mockItems, + }); + }); + + test('returns 400 on error', async () => { + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockRejectedValue(new Error('DB Error')), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getItems(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching items.', + }); + }); + }); + + describe('getItemsByCategory', () => { + test('successfully fetches items by category', async () => { + const mockItems = [{ name: 'Ingredient 1', category: 'INGREDIENT' }]; + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockResolvedValue(mockItems), + }); + + const req = makeReq({ params: { category: 'INGREDIENT' } }); + const res = makeRes(); + + await controller.getItemsByCategory(req, res); + + expect(KIInventoryItem.find).toHaveBeenCalledWith({ category: 'INGREDIENT' }, { __v: 0 }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'Items fetched successfully.', + data: mockItems, + }); + }); + + test('returns 400 on error', async () => { + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockRejectedValue(new Error('DB Error')), + }); + + const req = makeReq({ params: { category: 'INGREDIENT' } }); + const res = makeRes(); + + await controller.getItemsByCategory(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching items.', + }); + }); + }); + + describe('getPreservedStock', () => { + test('successfully fetches preserved items', async () => { + const mockItems = [{ name: 'Honey', category: 'INGREDIENT' }]; + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockResolvedValue(mockItems), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getPreservedStock(req, res); + + expect(KIInventoryItem.find).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'INGREDIENT', + expiryDate: expect.any(Object), + }), + { __v: 0 }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'Preserved stock items fetched successfully.', + data: mockItems, + }); + }); + + test('returns 400 on error', async () => { + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockReturnThis(), + sort: jest.fn().mockRejectedValue(new Error('DB Error')), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getPreservedStock(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching preserved stock items.', + }); + }); + }); + + describe('updateOnUsage', () => { + test('returns 400 if usedQuantity is 0 or negative', async () => { + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 0 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Used quantity must be greater than zero.', + }); + }); + + test('returns 404 if item not found', async () => { + KIInventoryItem.findById.mockResolvedValue(null); + + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 5 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Item not found.' }); + }); + + test('returns 400 if item is expired', async () => { + const expiredItem = { + expiryDate: new Date('2020-01-01'), + }; + KIInventoryItem.findById.mockResolvedValue(expiredItem); + + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 5 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].message).toContain('This item was expired on'); + }); + + test('successfully updates usage when quantity is sufficient', async () => { + const item = { + presentQuantity: 10, + expiryDate: new Date('2030-01-01'), + save: jest.fn().mockResolvedValue(true), + }; + KIInventoryItem.findById.mockResolvedValue(item); + + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 3 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(item.presentQuantity).toBe(7); + expect(item.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'Item usage updated successfully.', + data: item, + }); + }); + + test('caps presentQuantity at 0 if usage exceeds available', async () => { + const item = { + presentQuantity: 5, + expiryDate: new Date('2030-01-01'), + save: jest.fn().mockResolvedValue(true), + }; + KIInventoryItem.findById.mockResolvedValue(item); + + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 10 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(item.presentQuantity).toBe(0); + expect(item.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('returns 400 on DB error', async () => { + KIInventoryItem.findById.mockRejectedValue(new Error('DB Error')); + + const req = makeReq({ body: { itemId: VALID_OID, usedQuantity: 5 } }); + const res = makeRes(); + + await controller.updateOnUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'DB Error' }); + }); + }); + + describe('updateStoredQuantity', () => { + test('returns 400 if addedQuantity <= 0', async () => { + const req = makeReq({ body: { itemId: VALID_OID, addedQuantity: 0 } }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Added quantity must be greater than zero.', + }); + }); + + test('returns 400 if newExpiry is in the past', async () => { + const req = makeReq({ + body: { itemId: VALID_OID, addedQuantity: 5, newExpiry: '2020-01-01' }, + }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'New expiry date must be a future date.', + }); + }); + + test('returns 404 if item not found', async () => { + KIInventoryItem.findById.mockResolvedValue(null); + + const req = makeReq({ body: { itemId: VALID_OID, addedQuantity: 5 } }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Item not found.' }); + }); + + test('resets quantity if current item is expired', async () => { + const expiredItem = { + storedQuantity: 10, + presentQuantity: 5, + expiryDate: new Date('2020-01-01'), + save: jest.fn().mockResolvedValue(true), + }; + KIInventoryItem.findById.mockResolvedValue(expiredItem); + + const req = makeReq({ body: { itemId: VALID_OID, addedQuantity: 15 } }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(expiredItem.storedQuantity).toBe(15); + expect(expiredItem.presentQuantity).toBe(15); + expect(expiredItem.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('adds to existing quantity if item not expired', async () => { + const validItem = { + storedQuantity: 10, + presentQuantity: 5, + expiryDate: new Date('2030-01-01'), + save: jest.fn().mockResolvedValue(true), + }; + KIInventoryItem.findById.mockResolvedValue(validItem); + + const req = makeReq({ + body: { itemId: VALID_OID, addedQuantity: 15, newExpiry: '2031-01-01' }, + }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(validItem.storedQuantity).toBe(25); + expect(validItem.presentQuantity).toBe(20); + expect(validItem.expiryDate).toBe('2031-01-01'); + expect(validItem.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('returns 400 on error', async () => { + KIInventoryItem.findById.mockRejectedValue(new Error('DB Error')); + + const req = makeReq({ body: { itemId: VALID_OID, addedQuantity: 5 } }); + const res = makeRes(); + + await controller.updateStoredQuantity(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'DB Error' }); + }); + }); + + describe('updateNextHarvest', () => { + test('returns 404 if item not found', async () => { + KIInventoryItem.findById.mockResolvedValue(null); + + const req = makeReq({ body: { itemId: VALID_OID } }); + const res = makeRes(); + + await controller.updateNextHarvest(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Item not found.' }); + }); + + test('updates harvest details and copies next to last harvest if successful', async () => { + const item = { + nextHarvestDate: '2026-06-01', + lastHarvestDate: null, + save: jest.fn().mockResolvedValue(true), + }; + KIInventoryItem.findById.mockResolvedValue(item); + + const req = makeReq({ + body: { + itemId: VALID_OID, + lastHarvestSuccess: true, + nextHarvestDate: '2026-07-01', + nextHarvestQuantity: 50, + }, + }); + const res = makeRes(); + + await controller.updateNextHarvest(req, res); + + expect(item.lastHarvestDate).toBe('2026-06-01'); + expect(item.nextHarvestDate).toBe('2026-07-01'); + expect(item.nextHarvestQuantity).toBe(50); + expect(item.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('returns 400 on error', async () => { + KIInventoryItem.findById.mockRejectedValue(new Error('DB Error')); + + const req = makeReq({ body: { itemId: VALID_OID } }); + const res = makeRes(); + + await controller.updateNextHarvest(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'DB Error' }); + }); + }); + + describe('getInventoryStats', () => { + test('successfully returns calculated stats including preserved stock', async () => { + const farFuture = new Date(); + farFuture.setFullYear(farFuture.getFullYear() + 2); + + const mockItems = [ + { presentQuantity: 12, reorderAt: 20, category: 'INGREDIENT', expiryDate: farFuture }, + { presentQuantity: 4, reorderAt: 20, category: 'INGREDIENT', expiryDate: new Date() }, + { presentQuantity: 50, reorderAt: 20, category: 'SEEDS', expiryDate: farFuture }, + ]; + + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockResolvedValue(mockItems), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getInventoryStats(req, res); + + expect(KIInventoryItem.find).toHaveBeenCalledWith( + {}, + { presentQuantity: 1, reorderAt: 1, category: 1, expiryDate: 1 }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'Inventory stats fetched successfully.', + data: { + totalItems: 3, + criticalStock: 1, + lowStock: 1, + preserved: 1, + }, + }); + }); + + test('returns 500 on error', async () => { + KIInventoryItem.find.mockReturnValue({ + lean: jest.fn().mockRejectedValue(new Error('DB Error')), + }); + + const req = makeReq(); + const res = makeRes(); + + await controller.getInventoryStats(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching inventory stats.', + }); + }); + }); +}); diff --git a/src/models/bmdashboard/buildingToolsStoppage.js b/src/models/bmdashboard/buildingToolsStoppage.js index b9efc82e2..50fcbce4d 100644 --- a/src/models/bmdashboard/buildingToolsStoppage.js +++ b/src/models/bmdashboard/buildingToolsStoppage.js @@ -36,4 +36,4 @@ const toolsStoppageReasonSchema = new Schema( { collection: 'toolStoppageReason' }, ); -module.exports = mongoose.model('toolStoppageReason', toolsStoppageReasonSchema); \ No newline at end of file +module.exports = mongoose.model('toolStoppageReason', toolsStoppageReasonSchema); diff --git a/src/models/userTask.js b/src/models/userTask.js index 52b02c8d7..06f2e35a7 100644 --- a/src/models/userTask.js +++ b/src/models/userTask.js @@ -3,7 +3,7 @@ const mongoose = require('mongoose'); const userTaskSchema = new mongoose.Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, - role: { type: String, enum: ['student', 'educator'], required: true }, + role: { type: String, enum: ['student', 'educator', 'support'], required: true }, }); module.exports = mongoose.model('UserTask', userTaskSchema, 'usertask'); diff --git a/src/routes/kitchenandinventory/KIInventoryRouter.js b/src/routes/kitchenandinventory/KIInventoryRouter.js index dc4fcf83f..b8fb6eeb3 100644 --- a/src/routes/kitchenandinventory/KIInventoryRouter.js +++ b/src/routes/kitchenandinventory/KIInventoryRouter.js @@ -3,16 +3,23 @@ const controller = require('../../controllers/kitchenandinventory/KIInventoryCon const router = function () { const inventoryRouter = express.Router(); - // Routes for inventory items - inventoryRouter.route('/items').post(controller.addItem); // Route to add a new inventory item - inventoryRouter.route('/items').get(controller.getItems); // Route to get all inventory items - inventoryRouter.route('/items/:category').get(controller.getItemsByCategory); // Route to get items by category - inventoryRouter.route('/items/ingredients/preserved').get(controller.getPreservedStock); // Route to get preserved items - // Below update endpoints are non-idempotent and meant to be used for specific actions - inventoryRouter.route('/items/usage').post(controller.updateOnUsage); // Route to update item on usage - inventoryRouter.route('/items/storedQuantity').post(controller.updateStoredQuantity); // Route to update stored quantity - inventoryRouter.route('/items/nextHarvest').put(controller.updateNextHarvest); // Route to update next harvest details + + // ── Specific named routes (must come before /:category wildcard) ────────── + inventoryRouter.route('/items').get(controller.getItems); // Get all inventory items + inventoryRouter.route('/items').post(controller.addItem); // Add a new inventory item + inventoryRouter.route('/items/stats').get(controller.getInventoryStats); // Get total, critical & low stock counts + inventoryRouter.route('/items/ingredients/preserved').get(controller.getPreservedStock); // Get preserved items (expiry >= 1 yr) + + // ── Update endpoints (non-idempotent, specific actions) ─────────────────── + inventoryRouter.route('/items/usage').post(controller.updateOnUsage); // Update item on usage + inventoryRouter.route('/items/storedQuantity').post(controller.updateStoredQuantity); // Add new stock + inventoryRouter.route('/items/nextHarvest').put(controller.updateNextHarvest); // Update next harvest details + + // ── Wildcard route (must be last to avoid shadowing named routes above) ─── + inventoryRouter.route('/items/:category').get(controller.getItemsByCategory); // Get items by category + return inventoryRouter; }; module.exports = router; + diff --git a/src/scripts/seedKIInventoryItems.js b/src/scripts/seedKIInventoryItems.js new file mode 100644 index 000000000..aa5616b20 --- /dev/null +++ b/src/scripts/seedKIInventoryItems.js @@ -0,0 +1,497 @@ +/** + * Seed script for KIInventoryItems collection. + * + * Usage (from the HGNRest root): + * node src/scripts/seedKIInventoryItems.js + * + * Requires a valid .env file with the same MongoDB credentials used by the app: + * user, password, cluster, dbName, appName + * + * This script inserts sample inventory items across all 5 categories with + * a mix of stock levels (normal, low, critical) and some long-shelf-life + * ingredients (expiry >= 1 year) to trigger the preserved-items notification. + */ + +/* eslint-disable no-console */ +require('dotenv').config(); +const mongoose = require('mongoose'); +const KIInventoryItem = require('../models/kitchenandinventory/KIInventoryItems'); + +// Build the same Atlas URI the app uses in startup/db.js +const { user, password, cluster, dbName, appName = 'HGNRest' } = process.env; +if (!user || !password || !cluster || !dbName) { + console.error('❌ Missing required env vars: user, password, cluster, dbName'); + console.error(' Ensure your .env file is present and complete.'); + process.exit(1); +} +const MONGO_URI = `mongodb+srv://${user}:${encodeURIComponent(password)}@${cluster}/${dbName}?retryWrites=true&w=majority&appName=${appName}`; + +const now = new Date(); +const future = (months) => { + const d = new Date(now); + d.setMonth(d.getMonth() + months); + return d; +}; + +// Category enum values from the model +const CATEGORIES = { + INGREDIENT: 'INGREDIENT', + EQUIPMENTANDSUPPLIES: 'EQUIPEMENTANDSUPPLIES', // note: typo is intentional — matches model enum + SEEDS: 'SEEDS', + CANNINGSUPPLIES: 'CANNINGSUPPLIES', + ANIMALSUPPLIES: 'ANIMALSUPPLIES', +}; + +const seedItems = [ + // ── INGREDIENTS — mix of stock levels ──────────────────────────────────── + { + name: 'Canned Tomatoes', + storedQuantity: 120, + presentQuantity: 95, + unit: 'jars', + type: 'Preserved Foods', + monthlyUsage: 20, + reorderAt: 30, + category: CATEGORIES.INGREDIENT, + expiryDate: future(18), // > 1 year — triggers preserved notification + location: 'Pantry Shelf 1', + onsite: true, + }, + { + name: 'Pickled Cucumbers', + storedQuantity: 80, + presentQuantity: 65, + unit: 'jars', + type: 'Preserved Foods', + monthlyUsage: 15, + reorderAt: 20, + category: CATEGORIES.INGREDIENT, + expiryDate: future(24), // > 1 year — triggers preserved notification + location: 'Pantry Shelf 2', + onsite: true, + }, + { + name: 'Dried Beans', + storedQuantity: 50, + presentQuantity: 40, + unit: 'lbs', + type: 'Dry Goods', + monthlyUsage: 10, + reorderAt: 15, + category: CATEGORIES.INGREDIENT, + expiryDate: future(14), // > 1 year — triggers preserved notification + location: 'Dry Storage', + onsite: false, + }, + { + name: 'Carrots', + storedQuantity: 30, + presentQuantity: 8, // LOW: presentQuantity (8) <= reorderAt (10) + unit: 'lbs', + type: 'Vegetables', + monthlyUsage: 12, + reorderAt: 10, + category: CATEGORIES.INGREDIENT, + expiryDate: future(2), + location: 'Garden Bed 2', + onsite: true, + lastHarvestDate: future(-1), + nextHarvestDate: future(2), + nextHarvestQuantity: 25, + }, + { + name: 'Tomatoes', + storedQuantity: 60, + presentQuantity: 4, // CRITICAL: presentQuantity (4) <= reorderAt (30) * 0.5 (15) + unit: 'lbs', + type: 'Vegetables', + monthlyUsage: 25, + reorderAt: 30, + category: CATEGORIES.INGREDIENT, + expiryDate: future(1), + location: 'Garden Bed 1', + onsite: true, + nextHarvestDate: future(1), + nextHarvestQuantity: 30, + }, + { + name: 'Flour', + storedQuantity: 100, + presentQuantity: 70, + unit: 'lbs', + type: 'Dry Goods', + monthlyUsage: 20, + reorderAt: 25, + category: CATEGORIES.INGREDIENT, + expiryDate: future(6), + location: 'Dry Storage', + onsite: false, + }, + { + name: 'Honey', + storedQuantity: 40, + presentQuantity: 5, // CRITICAL: presentQuantity (5) <= reorderAt (20) * 0.5 (10) + unit: 'jars', + type: 'Sweetener', + monthlyUsage: 8, + reorderAt: 20, + category: CATEGORIES.INGREDIENT, + expiryDate: future(36), // > 1 year — triggers preserved notification + location: 'Pantry Shelf 3', + onsite: true, + }, + + // ── EQUIPMENT & SUPPLIES ────────────────────────────────────────────────── + { + name: 'Chef Knife Set', + storedQuantity: 5, + presentQuantity: 4, + unit: 'sets', + type: 'Kitchen Tools', + monthlyUsage: 0, + reorderAt: 2, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(60), + location: 'Kitchen Drawer 1', + onsite: true, + }, + { + name: 'Cast Iron Skillet', + storedQuantity: 8, + presentQuantity: 7, + unit: 'units', + type: 'Cookware', + monthlyUsage: 0, + reorderAt: 2, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(120), + location: 'Kitchen Cabinet', + onsite: true, + }, + { + name: 'Canning Jar Lids', + storedQuantity: 200, + presentQuantity: 15, // LOW: presentQuantity (15) <= reorderAt (30) + unit: 'units', + type: 'Canning Equipment', + monthlyUsage: 50, + reorderAt: 30, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(24), + location: 'Supply Cabinet', + onsite: false, + }, + { + name: 'Food Storage Bags', + storedQuantity: 150, + presentQuantity: 120, + unit: 'units', + type: 'Storage', + monthlyUsage: 30, + reorderAt: 40, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(18), + location: 'Supply Cabinet', + onsite: false, + }, + { + name: 'Mixing Bowls', + storedQuantity: 10, + presentQuantity: 2, // CRITICAL: presentQuantity (2) <= reorderAt (6) * 0.5 (3) + unit: 'sets', + type: 'Kitchen Tools', + monthlyUsage: 0, + reorderAt: 6, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(60), + location: 'Kitchen Cabinet', + onsite: true, + }, + { + name: 'Rubber Gloves', + storedQuantity: 100, + presentQuantity: 80, + unit: 'pairs', + type: 'Safety', + monthlyUsage: 20, + reorderAt: 20, + category: CATEGORIES.EQUIPMENTANDSUPPLIES, + expiryDate: future(12), + location: 'Supply Closet', + onsite: false, + }, + + // ── SEEDS ───────────────────────────────────────────────────────────────── + { + name: 'Tomato Seeds', + storedQuantity: 500, + presentQuantity: 400, + unit: 'packets', + type: 'Vegetable Seeds', + monthlyUsage: 50, + reorderAt: 100, + category: CATEGORIES.SEEDS, + expiryDate: future(18), + location: 'Seed Storage Box', + onsite: true, + }, + { + name: 'Bell Pepper Seeds', + storedQuantity: 300, + presentQuantity: 20, // LOW: presentQuantity (20) <= reorderAt (50) + unit: 'packets', + type: 'Vegetable Seeds', + monthlyUsage: 40, + reorderAt: 50, + category: CATEGORIES.SEEDS, + expiryDate: future(24), + location: 'Seed Storage Box', + onsite: true, + }, + { + name: 'Cucumber Seeds', + storedQuantity: 200, + presentQuantity: 150, + unit: 'packets', + type: 'Vegetable Seeds', + monthlyUsage: 30, + reorderAt: 60, + category: CATEGORIES.SEEDS, + expiryDate: future(12), + location: 'Seed Storage Box', + onsite: true, + }, + { + name: 'Herb Mix Seeds', + storedQuantity: 100, + presentQuantity: 10, // CRITICAL: presentQuantity (10) <= reorderAt (40) * 0.5 (20) + unit: 'packets', + type: 'Herb Seeds', + monthlyUsage: 20, + reorderAt: 40, + category: CATEGORIES.SEEDS, + expiryDate: future(8), + location: 'Seed Storage Box', + onsite: true, + }, + { + name: 'Squash Seeds', + storedQuantity: 150, + presentQuantity: 120, + unit: 'packets', + type: 'Vegetable Seeds', + monthlyUsage: 25, + reorderAt: 40, + category: CATEGORIES.SEEDS, + expiryDate: future(15), + location: 'Seed Storage Box', + onsite: true, + }, + { + name: 'Sunflower Seeds', + storedQuantity: 80, + presentQuantity: 60, + unit: 'lbs', + type: 'Oil Seeds', + monthlyUsage: 10, + reorderAt: 20, + category: CATEGORIES.SEEDS, + expiryDate: future(10), + location: 'Seed Storage Box', + onsite: true, + }, + + // ── CANNING SUPPLIES ────────────────────────────────────────────────────── + { + name: 'Mason Jars (16oz)', + storedQuantity: 300, + presentQuantity: 220, + unit: 'units', + type: 'Jars', + monthlyUsage: 40, + reorderAt: 60, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(120), + location: 'Canning Room Shelf 1', + onsite: false, + }, + { + name: 'Pectin Powder', + storedQuantity: 50, + presentQuantity: 8, // LOW: presentQuantity (8) <= reorderAt (15) + unit: 'lbs', + type: 'Canning Additive', + monthlyUsage: 5, + reorderAt: 15, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(9), + location: 'Canning Supply Cabinet', + onsite: false, + }, + { + name: 'Canning Salt', + storedQuantity: 40, + presentQuantity: 35, + unit: 'lbs', + type: 'Canning Additive', + monthlyUsage: 5, + reorderAt: 10, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(48), + location: 'Canning Supply Cabinet', + onsite: false, + }, + { + name: 'Pressure Canner Gaskets', + storedQuantity: 20, + presentQuantity: 3, // CRITICAL: presentQuantity (3) <= reorderAt (10) * 0.5 (5) + unit: 'units', + type: 'Canning Equipment Parts', + monthlyUsage: 2, + reorderAt: 10, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(24), + location: 'Canning Room Shelf 2', + onsite: false, + }, + { + name: 'Jar Lifter Tongs', + storedQuantity: 6, + presentQuantity: 6, + unit: 'units', + type: 'Canning Tools', + monthlyUsage: 0, + reorderAt: 2, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(60), + location: 'Canning Room Shelf 2', + onsite: false, + }, + { + name: 'Vinegar (White)', + storedQuantity: 60, + presentQuantity: 45, + unit: 'gallons', + type: 'Canning Liquid', + monthlyUsage: 8, + reorderAt: 15, + category: CATEGORIES.CANNINGSUPPLIES, + expiryDate: future(24), + location: 'Pantry Shelf 4', + onsite: false, + }, + + // ── ANIMAL SUPPLIES ─────────────────────────────────────────────────────── + { + name: 'Chicken Feed (Layer Pellets)', + storedQuantity: 500, + presentQuantity: 380, + unit: 'lbs', + type: 'Poultry Feed', + monthlyUsage: 80, + reorderAt: 100, + category: CATEGORIES.ANIMALSUPPLIES, + expiryDate: future(3), + location: 'Feed Barn Bin 1', + onsite: true, + }, + { + name: 'Goat Feed', + storedQuantity: 300, + presentQuantity: 25, // LOW: presentQuantity (25) <= reorderAt (50) + unit: 'lbs', + type: 'Livestock Feed', + monthlyUsage: 60, + reorderAt: 50, + category: CATEGORIES.ANIMALSUPPLIES, + expiryDate: future(2), + location: 'Feed Barn Bin 2', + onsite: true, + }, + { + name: 'Livestock Vitamins', + storedQuantity: 40, + presentQuantity: 5, // CRITICAL: presentQuantity (5) <= reorderAt (20) * 0.5 (10) + unit: 'bottles', + type: 'Animal Health', + monthlyUsage: 5, + reorderAt: 20, + category: CATEGORIES.ANIMALSUPPLIES, + expiryDate: future(6), + location: 'Vet Supply Cabinet', + onsite: true, + }, + { + name: 'Poultry Bedding (Straw)', + storedQuantity: 200, + presentQuantity: 160, + unit: 'bales', + type: 'Bedding', + monthlyUsage: 30, + reorderAt: 40, + category: CATEGORIES.ANIMALSUPPLIES, + expiryDate: future(6), + location: 'Barn Storage', + onsite: true, + }, + { + name: 'Animal Dewormer', + storedQuantity: 20, + presentQuantity: 15, + unit: 'doses', + type: 'Animal Health', + monthlyUsage: 3, + reorderAt: 5, + category: CATEGORIES.ANIMALSUPPLIES, + expiryDate: future(12), + location: 'Vet Supply Cabinet', + onsite: true, + }, +]; + +async function seed() { + try { + await mongoose.connect(MONGO_URI, { + useNewUrlParser: true, + useUnifiedTopology: true, + useFindAndModify: false, + }); + console.log(`✅ Connected to MongoDB (${dbName} on ${cluster})`); + + const existing = await KIInventoryItem.countDocuments(); + if (existing > 0) { + console.log(`⚠️ Collection already has ${existing} items. Clearing before re-seeding...`); + await KIInventoryItem.deleteMany({}); + console.log('✅ Collection cleared.'); + } + + const inserted = await KIInventoryItem.insertMany(seedItems); + console.log(`✅ Successfully seeded ${inserted.length} inventory items.`); + + // Quick stats summary + const total = inserted.length; + const critical = inserted.filter(i => i.presentQuantity <= i.reorderAt * 0.5).length; + const low = inserted.filter( + i => i.presentQuantity <= i.reorderAt && i.presentQuantity > i.reorderAt * 0.5, + ).length; + const preserved = inserted.filter( + i => + i.category === 'INGREDIENT' && + new Date(i.expiryDate) >= new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + ).length; + + console.log('\n📊 Seed Summary:'); + console.log(` Total items : ${total}`); + console.log(` Critical stock: ${critical}`); + console.log(` Low stock : ${low}`); + console.log(` Preserved : ${preserved} (ingredients expiring >= 1 year)`); + } catch (err) { + console.error('❌ Seed failed:', err.message); + process.exit(1); + } finally { + await mongoose.disconnect(); + console.log('\n✅ Disconnected from MongoDB.'); + } +} + +seed();