Files
Fridger/server.js
T

377 lines
12 KiB
JavaScript

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