const express = require('express'); const cors = require('cors'); const path = require('node:path'); const db = require('./db'); const { lookupBarcode } = require('./barcode'); const app = express(); const PORT = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // Metadata definitions const CATEGORIES = [ { id: 'Dairy & Cheese', name: 'Dairy & Cheese', icon: 'milk', color: '#3b82f6' }, { id: 'Meat & Fish', name: 'Meat & Fish', icon: 'beef', color: '#ef4444' }, { id: 'Produce', name: 'Fruits & Veggies', icon: 'apple', color: '#10b981' }, { id: 'Beverages', name: 'Drinks & Beverages', icon: 'cup-soda', color: '#06b6d4' }, { id: 'Sauces & Condiments', name: 'Sauces & Condiments', icon: 'flask-conical', color: '#f59e0b' }, { id: 'Bakery', name: 'Bakery & Bread', icon: 'croissant', color: '#d97706' }, { id: 'Prepared & Leftovers', name: 'Leftovers & Meals', icon: 'utensils', color: '#8b5cf6' }, { id: 'Snacks & Sweets', name: 'Snacks & Sweets', icon: 'cookie', color: '#ec4899' }, { id: 'General', name: 'General / Other', icon: 'package', color: '#6b7280' } ]; const LOCATIONS = [ 'Fridge - Top Shelf', 'Fridge - Middle Shelf', 'Fridge - Bottom Shelf', 'Fridge - Crisper Drawer', 'Fridge - Door Rack', 'Freezer - Top Drawer', 'Freezer - Bottom Drawer', 'Pantry / Cupboard' ]; const UNITS = [ { id: 'g', label: 'Grams (g)', isWeight: true, step: 50 }, { id: 'kg', label: 'Kilograms (kg)', isWeight: true, step: 0.1 }, { id: 'ml', label: 'Milliliters (ml)', isVolume: true, step: 100 }, { id: 'l', label: 'Liters (L)', isVolume: true, step: 0.5 }, { id: 'pcs', label: 'Pieces (Stk / pcs)', isCount: true, step: 1 }, { id: 'slices', label: 'Slices', isCount: true, step: 1 }, { id: 'pack', label: 'Pack / Package', isCount: true, step: 1 } ]; // Health Check app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }); }); // Meta endpoint app.get('/api/meta', (req, res) => { res.json({ categories: CATEGORIES, locations: LOCATIONS, units: UNITS }); }); // Stats endpoint app.get('/api/stats', (req, res) => { try { const stats = db.getStats(); res.json(stats); } catch (err) { res.status(500).json({ error: err.message }); } }); // Activity logs app.get('/api/activity', (req, res) => { try { const limit = parseInt(req.query.limit, 10) || 50; const logs = db.getActivityLogs(limit); res.json(logs); } catch (err) { res.status(500).json({ error: err.message }); } }); // Barcode lookup app.get('/api/barcode/:code', async (req, res) => { const barcode = req.params.code; if (!barcode) { return res.status(400).json({ error: 'Barcode parameter is required' }); } // First check if user previously stored an item with this barcode const existing = db.getItemByBarcode(barcode); if (existing) { return res.json({ found: true, fromLocal: true, barcode: existing.barcode, name: existing.name, brand: existing.brand || '', category: existing.category || 'General', location: existing.location || 'Fridge - Middle Shelf', quantity: existing.initial_quantity || existing.quantity, initial_quantity: existing.initial_quantity || existing.quantity, unit: existing.unit || 'g', image_url: existing.image_url || '' }); } // Lookup in Open Food Facts const product = await lookupBarcode(barcode); if (product) { return res.json({ ...product, fromLocal: false }); } res.status(404).json({ found: false, barcode: barcode, message: 'Barcode not found in database or Open Food Facts' }); }); // List items app.get('/api/items', (req, res) => { try { const { search, category, location, status } = req.query; const items = db.getAllItems({ search, category, location, status }); res.json(items); } catch (err) { res.status(500).json({ error: err.message }); } }); // Get single item app.get('/api/items/:id', (req, res) => { try { const item = db.getItemById(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Create item app.post('/api/items', (req, res) => { try { const { name, quantity } = req.body; if (!name || name.trim() === '') { return res.status(400).json({ error: 'Item name is required' }); } if (quantity === undefined || isNaN(Number(quantity)) || Number(quantity) < 0) { return res.status(400).json({ error: 'Valid quantity is required' }); } const item = db.createItem(req.body); res.status(201).json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Update item app.put('/api/items/:id', (req, res) => { try { const item = db.updateItem(req.params.id, req.body); if (!item) return res.status(404).json({ error: 'Item not found' }); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Quick adjust quantity (grams / count / delta / slider) app.patch('/api/items/:id/quantity', (req, res) => { try { const { newQuantity, delta, markOpened } = req.body; if (newQuantity === undefined && delta === undefined) { return res.status(400).json({ error: 'Either newQuantity or delta must be provided' }); } const item = db.adjustQuantity(req.params.id, { newQuantity, delta, markOpened }); if (!item) return res.status(404).json({ error: 'Item not found' }); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Add whole packet (+1 full pack size) app.post('/api/items/:id/add-packet', (req, res) => { try { const count = parseInt(req.body.count, 10) || 1; const item = db.addPacket(req.params.id, count); if (!item) return res.status(404).json({ error: 'Item not found' }); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Remove whole packet (-1 full pack size) app.post('/api/items/:id/remove-packet', (req, res) => { try { const count = parseInt(req.body.count, 10) || 1; const item = db.removePacket(req.params.id, count); if (!item) return res.status(404).json({ error: 'Item not found' }); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // Delete item app.delete('/api/items/:id', (req, res) => { try { const success = db.deleteItem(req.params.id); if (!success) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true, message: 'Item removed' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Seed sample fridge data if empty app.post('/api/seed', (req, res) => { try { const existing = db.getAllItems(); if (existing.length > 0 && !req.query.force) { return res.json({ message: 'Fridge already contains items', count: existing.length }); } const today = new Date(); const addDays = (d) => { const target = new Date(today.getTime() + d * 24 * 60 * 60 * 1000); return target.toISOString().slice(0, 10); }; const samples = [ { name: 'Gouda Cheese (Jung)', brand: 'Milbona', barcode: '4056489123456', category: 'Dairy & Cheese', location: 'Fridge - Middle Shelf', quantity: 200, initial_quantity: 400, unit: 'g', is_opened: 1, opened_at: addDays(-2), expiry_date: addDays(6), notes: 'Opened, about half remaining for sandwiches' }, { name: 'Whole Milk 3.5%', brand: 'Weihenstephan', barcode: '4008452023451', category: 'Dairy & Cheese', location: 'Fridge - Door Rack', quantity: 650, initial_quantity: 1000, unit: 'ml', is_opened: 1, opened_at: addDays(-1), expiry_date: addDays(3), notes: 'Carton opened yesterday' }, { name: 'Organic Greek Yogurt', brand: 'Andechser Natur', barcode: '4005040001234', category: 'Dairy & Cheese', location: 'Fridge - Top Shelf', quantity: 500, initial_quantity: 500, unit: 'g', is_opened: 0, expiry_date: addDays(12), notes: 'Unopened 500g cup' }, { name: 'Chicken Breast Fillet', brand: 'Bauerngut', barcode: '4012345678901', category: 'Meat & Fish', location: 'Fridge - Bottom Shelf', quantity: 350, initial_quantity: 400, unit: 'g', is_opened: 1, opened_at: addDays(0), expiry_date: addDays(1), notes: 'Cook today or freeze!' }, { name: 'Fresh Cherry Tomatoes', brand: 'Bio', category: 'Produce', location: 'Fridge - Crisper Drawer', quantity: 180, initial_quantity: 250, unit: 'g', is_opened: 1, expiry_date: addDays(4), notes: 'Sweet snack tomatoes' }, { name: 'Dijon Mustard', brand: 'Maille', barcode: '3036810200028', category: 'Sauces & Condiments', location: 'Fridge - Door Rack', quantity: 140, initial_quantity: 215, unit: 'g', is_opened: 1, expiry_date: addDays(90), notes: 'In door rack' }, { name: 'Butter (Irish Pure)', brand: 'Kerrygold', barcode: '5011010001018', category: 'Dairy & Cheese', location: 'Fridge - Door Rack', quantity: 190, initial_quantity: 250, unit: 'g', is_opened: 1, expiry_date: addDays(25), notes: 'In butter dish' }, { name: 'Homemade Pasta Bolognese', brand: '', category: 'Prepared & Leftovers', location: 'Fridge - Top Shelf', quantity: 1, initial_quantity: 1, unit: 'pack', is_opened: 1, opened_at: addDays(-1), expiry_date: addDays(1), notes: 'Glass container, 1 portion' }, { name: 'Orange Juice 100%', brand: 'Innocent', barcode: '5038862112345', category: 'Beverages', location: 'Fridge - Door Rack', quantity: 900, initial_quantity: 900, unit: 'ml', is_opened: 0, expiry_date: addDays(14) } ]; for (const sample of samples) { db.createItem(sample); } res.json({ message: 'Seed complete', created: samples.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Catch-all route to serve the Single Page Application app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Start Server if (require.main === module) { // Check if initial seed is needed on first launch const items = db.getAllItems(); if (items.length === 0) { console.log('Fridge is empty. Pre-seeding sample items for initial experience...'); // Seed initial items const today = new Date(); const addDays = (d) => new Date(today.getTime() + d * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); [ { name: 'Gouda Cheese', brand: 'Milbona', barcode: '4056489123456', category: 'Dairy & Cheese', location: 'Fridge - Middle Shelf', quantity: 200, initial_quantity: 400, unit: 'g', is_opened: 1, expiry_date: addDays(5), notes: 'Half pack left' }, { name: 'Fresh Milk 3.5%', brand: 'Weihenstephan', barcode: '4008452023451', category: 'Dairy & Cheese', location: 'Fridge - Door Rack', quantity: 700, initial_quantity: 1000, unit: 'ml', is_opened: 1, expiry_date: addDays(3), notes: 'Opened' }, { name: 'Butter', brand: 'Kerrygold', barcode: '5011010001018', category: 'Dairy & Cheese', location: 'Fridge - Door Rack', quantity: 210, initial_quantity: 250, unit: 'g', is_opened: 1, expiry_date: addDays(20) }, { name: 'Chicken Breast', brand: 'Bio', category: 'Meat & Fish', location: 'Fridge - Bottom Shelf', quantity: 400, initial_quantity: 400, unit: 'g', is_opened: 0, expiry_date: addDays(2), notes: 'Cook soon' }, { name: 'Cherry Tomatoes', category: 'Produce', location: 'Fridge - Crisper Drawer', quantity: 180, initial_quantity: 250, unit: 'g', is_opened: 1, expiry_date: addDays(4) }, { name: 'Dijon Mustard', brand: 'Maille', barcode: '3036810200028', category: 'Sauces & Condiments', location: 'Fridge - Door Rack', quantity: 150, initial_quantity: 215, unit: 'g', is_opened: 1, expiry_date: addDays(60) } ].forEach(item => db.createItem(item)); } app.listen(PORT, '0.0.0.0', () => { console.log(`🧊 Fridger server running at http://0.0.0.0:${PORT}`); }); } module.exports = app;