feat(core): implement sqlite database, express api, and openfoodfacts barcode integration
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const DATA_DIR = process.env.FRIDGER_DATA_DIR || path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const DB_PATH = process.env.FRIDGER_DB_PATH || path.join(DATA_DIR, 'fridger.db');
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
|
||||
// Initialize Tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
barcode TEXT,
|
||||
brand TEXT,
|
||||
category TEXT DEFAULT 'General',
|
||||
location TEXT DEFAULT 'Fridge - Middle Shelf',
|
||||
quantity REAL NOT NULL,
|
||||
initial_quantity REAL NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'g',
|
||||
expiry_date TEXT,
|
||||
image_url TEXT,
|
||||
notes TEXT,
|
||||
is_opened INTEGER DEFAULT 0,
|
||||
opened_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER,
|
||||
item_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
amount_change REAL,
|
||||
unit TEXT,
|
||||
timestamp TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_barcode ON items(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_expiry ON items(expiry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
|
||||
`);
|
||||
|
||||
// Item functions
|
||||
function getAllItems({ search = '', category = '', location = '', status = 'all' } = {}) {
|
||||
let query = `SELECT * FROM items WHERE 1=1`;
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
query += ` AND (name LIKE ? OR brand LIKE ? OR barcode LIKE ? OR notes LIKE ?)`;
|
||||
const s = `%${search}%`;
|
||||
params.push(s, s, s, s);
|
||||
}
|
||||
|
||||
if (category && category !== 'All') {
|
||||
query += ` AND category = ?`;
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
if (location && location !== 'All') {
|
||||
query += ` AND location = ?`;
|
||||
params.push(location);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (status === 'expired') {
|
||||
query += ` AND expiry_date IS NOT NULL AND expiry_date != '' AND expiry_date < ?`;
|
||||
params.push(today);
|
||||
} else if (status === 'expiring_soon') {
|
||||
const next3Days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
query += ` AND expiry_date IS NOT NULL AND expiry_date != '' AND expiry_date >= ? AND expiry_date <= ?`;
|
||||
params.push(today, next3Days);
|
||||
} else if (status === 'fresh') {
|
||||
const next3Days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
query += ` AND (expiry_date IS NULL OR expiry_date = '' OR expiry_date > ?)`;
|
||||
params.push(next3Days);
|
||||
} else if (status === 'low') {
|
||||
query += ` AND (quantity <= (initial_quantity * 0.25) OR quantity = 0)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY
|
||||
CASE
|
||||
WHEN expiry_date IS NULL OR expiry_date = '' THEN 1
|
||||
ELSE 0
|
||||
END,
|
||||
expiry_date ASC,
|
||||
updated_at DESC`;
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params);
|
||||
}
|
||||
|
||||
function getItemById(id) {
|
||||
const stmt = db.prepare(`SELECT * FROM items WHERE id = ?`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
function getItemByBarcode(barcode) {
|
||||
const stmt = db.prepare(`SELECT * FROM items WHERE barcode = ? ORDER BY updated_at DESC LIMIT 1`);
|
||||
return stmt.get(barcode);
|
||||
}
|
||||
|
||||
function createItem(item) {
|
||||
const initial_qty = Number(item.initial_quantity) || Number(item.quantity) || 1;
|
||||
const current_qty = Number(item.quantity) ?? initial_qty;
|
||||
const unit = (item.unit || 'g').trim();
|
||||
const is_opened = item.is_opened ? 1 : 0;
|
||||
const opened_at = is_opened ? (item.opened_at || new Date().toISOString().slice(0, 10)) : null;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO items (
|
||||
name, barcode, brand, category, location,
|
||||
quantity, initial_quantity, unit, expiry_date,
|
||||
image_url, notes, is_opened, opened_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
item.name,
|
||||
item.barcode || null,
|
||||
item.brand || null,
|
||||
item.category || 'General',
|
||||
item.location || 'Fridge - Middle Shelf',
|
||||
current_qty,
|
||||
initial_qty,
|
||||
unit,
|
||||
item.expiry_date || null,
|
||||
item.image_url || null,
|
||||
item.notes || null,
|
||||
is_opened,
|
||||
opened_at
|
||||
);
|
||||
|
||||
const newId = result.lastInsertRowid;
|
||||
|
||||
// Log activity
|
||||
logActivity({
|
||||
item_id: newId,
|
||||
item_name: item.name,
|
||||
action: 'add',
|
||||
amount_change: current_qty,
|
||||
unit: unit,
|
||||
note: `Added ${current_qty} ${unit}`
|
||||
});
|
||||
|
||||
return getItemById(newId);
|
||||
}
|
||||
|
||||
function updateItem(id, item) {
|
||||
const existing = getItemById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const qty = item.quantity !== undefined ? Number(item.quantity) : existing.quantity;
|
||||
const init_qty = item.initial_quantity !== undefined ? Number(item.initial_quantity) : existing.initial_quantity;
|
||||
const is_opened = item.is_opened !== undefined ? (item.is_opened ? 1 : 0) : existing.is_opened;
|
||||
let opened_at = existing.opened_at;
|
||||
if (is_opened && !existing.is_opened) {
|
||||
opened_at = new Date().toISOString().slice(0, 10);
|
||||
} else if (!is_opened) {
|
||||
opened_at = null;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE items SET
|
||||
name = ?,
|
||||
barcode = ?,
|
||||
brand = ?,
|
||||
category = ?,
|
||||
location = ?,
|
||||
quantity = ?,
|
||||
initial_quantity = ?,
|
||||
unit = ?,
|
||||
expiry_date = ?,
|
||||
image_url = ?,
|
||||
notes = ?,
|
||||
is_opened = ?,
|
||||
opened_at = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
item.name ?? existing.name,
|
||||
item.barcode !== undefined ? item.barcode : existing.barcode,
|
||||
item.brand !== undefined ? item.brand : existing.brand,
|
||||
item.category ?? existing.category,
|
||||
item.location ?? existing.location,
|
||||
qty,
|
||||
init_qty,
|
||||
item.unit ?? existing.unit,
|
||||
item.expiry_date !== undefined ? item.expiry_date : existing.expiry_date,
|
||||
item.image_url !== undefined ? item.image_url : existing.image_url,
|
||||
item.notes !== undefined ? item.notes : existing.notes,
|
||||
is_opened,
|
||||
opened_at,
|
||||
id
|
||||
);
|
||||
|
||||
if (qty !== existing.quantity) {
|
||||
const diff = qty - existing.quantity;
|
||||
logActivity({
|
||||
item_id: id,
|
||||
item_name: item.name || existing.name,
|
||||
action: diff < 0 ? 'consume_part' : 'restock',
|
||||
amount_change: diff,
|
||||
unit: item.unit || existing.unit,
|
||||
note: `Adjusted quantity from ${existing.quantity} to ${qty} ${item.unit || existing.unit}`
|
||||
});
|
||||
}
|
||||
|
||||
return getItemById(id);
|
||||
}
|
||||
|
||||
function adjustQuantity(id, { newQuantity, delta, markOpened }) {
|
||||
const item = getItemById(id);
|
||||
if (!item) return null;
|
||||
|
||||
let targetQty = item.quantity;
|
||||
if (newQuantity !== undefined) {
|
||||
targetQty = Number(newQuantity);
|
||||
} else if (delta !== undefined) {
|
||||
targetQty = item.quantity + Number(delta);
|
||||
}
|
||||
|
||||
// Prevent negative quantity
|
||||
if (targetQty < 0) targetQty = 0;
|
||||
|
||||
const is_opened = markOpened !== undefined ? (markOpened ? 1 : 0) : (item.is_opened || (targetQty < item.initial_quantity ? 1 : 0));
|
||||
const opened_at = is_opened && !item.opened_at ? new Date().toISOString().slice(0, 10) : item.opened_at;
|
||||
|
||||
const diff = targetQty - item.quantity;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE items SET
|
||||
quantity = ?,
|
||||
is_opened = ?,
|
||||
opened_at = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(targetQty, is_opened, opened_at, id);
|
||||
|
||||
logActivity({
|
||||
item_id: id,
|
||||
item_name: item.name,
|
||||
action: targetQty === 0 ? 'consume_all' : (diff < 0 ? 'consume_part' : 'restock'),
|
||||
amount_change: diff,
|
||||
unit: item.unit,
|
||||
note: targetQty === 0
|
||||
? `Consumed all (${item.quantity} ${item.unit})`
|
||||
: `${diff < 0 ? 'Consumed' : 'Added'} ${Math.abs(diff)} ${item.unit} (remaining: ${targetQty} ${item.unit})`
|
||||
});
|
||||
|
||||
return getItemById(id);
|
||||
}
|
||||
|
||||
function deleteItem(id) {
|
||||
const item = getItemById(id);
|
||||
if (!item) return false;
|
||||
|
||||
logActivity({
|
||||
item_id: id,
|
||||
item_name: item.name,
|
||||
action: 'delete',
|
||||
amount_change: -item.quantity,
|
||||
unit: item.unit,
|
||||
note: `Deleted item from fridge`
|
||||
});
|
||||
|
||||
const stmt = db.prepare(`DELETE FROM items WHERE id = ?`);
|
||||
stmt.run(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function logActivity({ item_id, item_name, action, amount_change, unit, note }) {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO activity_log (item_id, item_name, action, amount_change, unit, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(item_id || null, item_name || null, action, amount_change || 0, unit || '', note || '');
|
||||
} catch (err) {
|
||||
console.error('Error logging activity:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function getActivityLogs(limit = 50) {
|
||||
const stmt = db.prepare(`SELECT * FROM activity_log ORDER BY timestamp DESC, id DESC LIMIT ?`);
|
||||
return stmt.all(limit);
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const next3Days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const totalStmt = db.prepare(`SELECT COUNT(*) as count FROM items`);
|
||||
const total = totalStmt.get().count;
|
||||
|
||||
const expiredStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM items
|
||||
WHERE expiry_date IS NOT NULL AND expiry_date != '' AND expiry_date < ?
|
||||
`);
|
||||
const expired = expiredStmt.get(today).count;
|
||||
|
||||
const expiringSoonStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM items
|
||||
WHERE expiry_date IS NOT NULL AND expiry_date != '' AND expiry_date >= ? AND expiry_date <= ?
|
||||
`);
|
||||
const expiringSoon = expiringSoonStmt.get(today, next3Days).count;
|
||||
|
||||
const openedStmt = db.prepare(`SELECT COUNT(*) as count FROM items WHERE is_opened = 1`);
|
||||
const opened = openedStmt.get().count;
|
||||
|
||||
const lowStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM items
|
||||
WHERE quantity <= (initial_quantity * 0.25) AND quantity > 0
|
||||
`);
|
||||
const low = lowStmt.get().count;
|
||||
|
||||
return {
|
||||
total,
|
||||
expired,
|
||||
expiringSoon,
|
||||
opened,
|
||||
low,
|
||||
fresh: total - expired - expiringSoon
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
getItemByBarcode,
|
||||
createItem,
|
||||
updateItem,
|
||||
adjustQuantity,
|
||||
deleteItem,
|
||||
getActivityLogs,
|
||||
getStats
|
||||
};
|
||||
Reference in New Issue
Block a user