Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m36s
401 lines
13 KiB
JavaScript
401 lines
13 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 });
|
|
}
|
|
});
|
|
|
|
// 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;
|