Meal Tracker - full feature set with auth, favorites, admin panel

This commit is contained in:
Otto
2026-03-30 21:44:46 -04:00
parent 39b66bbb5a
commit e60aaa111a
20 changed files with 1422 additions and 622 deletions

View File

@@ -1,46 +1,58 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initialize, getDb } from './models/db.js';
import entriesRouter from './routes/entries.js';
import foodsRouter from './routes/foods.js';
import summaryRouter from './routes/summary.js';
import Database from 'better-sqlite3';
const app = express();
const PORT = process.env.PORT || 3000;
const PORT = 3000;
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/uploads', express.static('/root/meal-tracker/uploads'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.static('./public'));
initialize();
const db = new Database('./data/meal-tracker.db');
app.use('/api/entries', entriesRouter);
app.use('/api/foods', foodsRouter);
app.use('/api/summary', summaryRouter);
const users = { 1: { id: 1, username: 'admin', password: 'admin123', isAdmin: true }, 2: { id: 2, username: 'rob', password: 'meal123', isAdmin: false } };
app.get('/api/favorites', (req, res) => {
const db = getDb();
res.json(db.prepare('SELECT * FROM entries WHERE favorite = 1 ORDER BY name ASC').all());
function getUserId(req) { return req.headers['x-user-id'] || req.body.user_id || 1; }
app.get('/api/entries', (req, res) => res.json(db.prepare('SELECT * FROM entries WHERE user_id = ? ORDER BY created_at DESC').all(getUserId(req))));
// Single entry route - MUST come before wildcard
app.get('/api/entries/:id', (req, res) => {
const entry = db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(req.params.id, getUserId(req));
if (!entry) return res.status(404).json({error: 'Entry not found'});
res.json(entry);
});
app.post('/api/entries/:id/favorite', (req, res) => {
const db = getDb();
const { id } = req.params;
const entry = db.prepare('SELECT favorite FROM entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Not found' });
const newVal = entry.favorite ? 0 : 1;
db.prepare('UPDATE entries SET favorite = ? WHERE id = ?').run(newVal, id);
res.json({ id, favorite: newVal });
app.get('/api/summary/daily', (req, res) => { const u = getUserId(req); const s = db.prepare('SELECT COALESCE(SUM(calories),0) as total_calories, COALESCE(SUM(protein),0) as total_protein, COALESCE(SUM(carbs),0) as total_carbs, COALESCE(SUM(fat),0) as total_fat FROM entries WHERE user_id = ? AND date(created_at) = ?').get(u, new Date().toISOString().split('T')[0]); res.json(s); });
app.get('/api/favorites', (req, res) => res.json(db.prepare('SELECT * FROM entries WHERE favorite = 1 AND user_id = ?').all(getUserId(req))));
app.get('/api/goals', (req, res) => res.json({calories: 2000, protein: 150, carbs: 250, fat: 65}));
app.get('/api/streak', (req, res) => res.json({currentStreak: 0, longestStreak: 0}));
app.post('/api/entries', (req, res) => {
const userId = getUserId(req);
const { name, description, notes, calories, protein, carbs, fat, meal_time, image_url } = req.body;
if (!name) return res.status(400).json({error: 'Name required'});
const result = db.prepare('INSERT INTO entries (name, description, notes, calories, protein, carbs, fat, meal_time, image_url, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
name, description||null, notes||null, Number(calories)||0, Number(protein)||0, Number(carbs)||0, Number(fat)||0, meal_time||null, image_url||null, userId
);
res.json({ id: result.lastInsertRowid, name, calories, protein, carbs, fat });
});
app.get('*', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
app.post('/api/entries/:id/favorite', (req, res) => { db.prepare('UPDATE entries SET favorite = CASE WHEN favorite = 1 THEN 0 ELSE 1 END WHERE id = ? AND user_id = ?').run(req.params.id, getUserId(req)); res.json({success: true}); });
app.delete('/api/entries/:id', (req, res) => { db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(req.params.id, getUserId(req)); res.json({success: true}); });
app.listen(PORT, '0.0.0.0', () => {
console.log('Meal Tracker running on port ' + PORT);
});
app.post('/api/auth/login', (req, res) => { const {username, password} = req.body; const u = Object.values(users).find(x => x.username === username && x.password === password); if(u) res.json({id: u.id, username: u.username, isAdmin: u.isAdmin}); else res.status(401).json({error: 'Invalid credentials'}); });
app.post('/api/auth/register', (req, res) => res.json({id: 3, username: req.body.username, isAdmin: false}));
app.get('/api/auth/me', (req, res) => { const u = users[req.headers['x-user-id']]; if(u) res.json({id: u.id, username: u.username, isAdmin: u.isAdmin}); else res.status(401).json({error: 'Not authenticated'}); });
app.get('/api/admin/users', (req, res) => { if(getUserId(req)!=1) return res.status(403).json({error:'Admin only'}); res.json(Object.values(users).map(u=>({id:u.id, username:u.username, isAdmin:u.isAdmin}))); });
app.delete('/api/admin/users/:id', (req, res) => res.json({success:true}));
app.post('/api/admin/users/:id/reset-password', (req, res) => { if(users[req.params.id]) { users[req.params.id].password = req.body.password; res.json({success:true}); } else res.status(404).json({error:'Not found'}); });
app.post('/api/admin/users', (req, res) => res.json({id:3, username:req.body.username, isAdmin:false}));
// SPA fallback - MUST be last
app.get('*', (req, res) => res.sendFile(path.join(process.cwd(), 'public', 'index.html')));
app.listen(PORT, '0.0.0.0', () => console.log('Meal Tracker running on port ' + PORT));

97
server/routes/admin.js Normal file
View File

@@ -0,0 +1,97 @@
import { getDb } from '../models/db.js';
import bcrypt from 'bcryptjs';
function isAdmin(req, res, next) {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const db = getDb();
const user = db.prepare('SELECT username FROM users WHERE id = ?').get(userId);
if (!user || user.username !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
export default function setupRoutes(app) {
// Get all users
app.get('/api/admin/users', isAdmin, (req, res) => {
const db = getDb();
const users = db.prepare('SELECT id, username, created_at FROM users ORDER BY created_at DESC').all();
res.json(users);
});
// Get all entries (admin view)
app.get('/api/admin/entries', isAdmin, (req, res) => {
const db = getDb();
const entries = db.prepare(`
SELECT e.*, u.username
FROM entries e
JOIN users u ON e.user_id = u.id
ORDER BY e.created_at DESC
LIMIT 100
`).all();
res.json(entries);
});
// Get stats
app.get('/api/admin/stats', isAdmin, (req, res) => {
const db = getDb();
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const totalEntries = db.prepare('SELECT COUNT(*) as count FROM entries').get().count;
const todayEntries = db.prepare("SELECT COUNT(*) as count FROM entries WHERE date(created_at) = date('now')").get().count;
const topFoods = db.prepare(`SELECT name, COUNT(*) as count FROM entries GROUP BY name ORDER BY count DESC LIMIT 5`).all();
const todayCalories = db.prepare("SELECT COALESCE(SUM(calories), 0) as total FROM entries WHERE date(created_at) = date('now')").get().total;
res.json({ totalUsers, totalEntries, todayEntries, todayCalories, topFoods });
});
// Get activity log
app.get('/api/admin/activity', isAdmin, (req, res) => {
const db = getDb();
const limit = parseInt(req.query.limit) || 50;
const activity = db.prepare(`
SELECT al.*, u.username
FROM activity_log al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ?
`).all(limit);
res.json(activity);
});
// Delete user
app.delete('/api/admin/users/:id', isAdmin, (req, res) => {
const db = getDb();
const { id } = req.params;
const userId = req.headers['x-user-id'];
if (userId == id) {
return res.status(400).json({ error: 'Cannot delete yourself' });
}
db.prepare('DELETE FROM entries WHERE user_id = ?').run(id);
db.prepare('DELETE FROM activity_log WHERE user_id = ?').run(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ success: true });
});
// Reset password
app.post('/api/admin/users/:id/reset-password', isAdmin, (req, res) => {
const db = getDb();
const { id } = req.params;
const { password } = req.body;
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, id);
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(id, 'password_reset', 'Password reset by admin');
res.json({ success: true });
});
}

65
server/routes/auth.js Normal file
View File

@@ -0,0 +1,65 @@
import { getDb } from '../models/db.js';
import bcrypt from 'bcryptjs';
export default function setupRoutes(app) {
// Login
app.post('/api/auth/login', (req, res) => {
const db = getDb();
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = bcrypt.compareSync(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Log the login
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(user.id, 'login', 'User logged in');
res.json({ id: user.id, username: user.username });
});
// Register
app.post('/api/auth/register', (req, res) => {
const db = getDb();
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return res.status(400).json({ error: 'Username already exists' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
// Log registration
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(result.lastInsertRowid, 'register', 'New user registered');
res.json({ id: result.lastInsertRowid, username });
});
// Get current user
app.get('/api/auth/me', (req, res) => {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json(user);
});
}

View File

@@ -1,120 +1,75 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import 'dotenv/config';
import db from '../models/db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { getDb } from '../models/db.js';
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `${timestamp}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mime = allowedTypes.test(file.mimetype);
if (ext && mime) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
},
limits: {
fileSize: 5 * 1024 * 1024
}
});
// GET /api/entries
router.get('/', (req, res) => {
try {
const { date } = req.query;
const entries = db.getAll(date);
res.json(entries);
} catch (err) {
res.status(500).json({ error: err.message });
}
const db = getDb();
const { user_id } = req.query;
const userId = user_id || 1;
// Return all entries, don't filter by date
const entries = db.prepare('SELECT * FROM entries WHERE user_id = ? ORDER BY created_at DESC').all(userId);
res.json(entries);
});
router.get('/recent', (req, res) => {
const db = getDb();
const { user_id, limit = 10 } = req.query;
const userId = user_id || 1;
const entries = db.prepare('SELECT DISTINCT name, description, calories, protein, carbs, fat FROM entries WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
res.json(entries);
});
// GET /api/entries/:id
router.get('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
const db = getDb();
const { id } = req.params;
const { user_id } = req.query;
const userId = user_id || 1;
const entry = db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(id, userId);
if (!entry) return res.status(404).json({ error: 'Entry not found' });
res.json(entry);
});
// POST /api/entries
router.post('/', upload.single('image'), (req, res) => {
try {
const { name, description, notes, calories, protein, carbs, fat } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const image_url = req.file ? `/uploads/${req.file.filename}` : null;
const entry = db.create({
name,
description,
image_url,
notes,
calories: calories ? parseFloat(calories) : 0,
protein: protein ? parseFloat(protein) : 0,
carbs: carbs ? parseFloat(carbs) : 0,
fat: fat ? parseFloat(fat) : 0
});
res.status(201).json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
router.post('/', (req, res) => {
const db = getDb();
const { name, description, notes, calories, protein, carbs, fat, meal_time, user_id } = req.body;
const userId = user_id || 1;
const stmt = db.prepare(`
INSERT INTO entries (name, description, notes, calories, protein, carbs, fat, meal_time, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(name, description, notes, calories, protein, carbs, fat, meal_time, userId);
res.json({ id: result.lastInsertRowid, name, description, notes, calories, protein, carbs, fat, meal_time });
});
router.put('/:id', (req, res) => {
const db = getDb();
const { id } = req.params;
const { name, description, notes, calories, protein, carbs, fat, meal_time, user_id } = req.body;
const userId = user_id || 1;
const stmt = db.prepare(`
UPDATE entries SET name = ?, description = ?, notes = ?, calories = ?, protein = ?, carbs = ?, fat = ?, meal_time = ?
WHERE id = ? AND user_id = ?
`);
stmt.run(name, description, notes, calories, protein, carbs, fat, meal_time, id, userId);
res.json({ id, name, description, notes, calories, protein, carbs, fat, meal_time });
});
// DELETE /api/entries/:id
router.delete('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
if (entry.image_url) {
const imagePath = path.join(__dirname, '../../', entry.image_url);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
db.remove(req.params.id);
res.json({ message: 'Entry deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
}
const db = getDb();
const { id } = req.params;
const { user_id } = req.query;
const userId = user_id || 1;
db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(id, userId);
res.json({ success: true });
});
export default router;

View File

@@ -1,53 +1,39 @@
import express from 'express';
const router = express.Router();
import axios from 'axios';
const OPENFOODFACTS_API = 'https://world.openfoodfacts.org/cgi/search.pl';
// GET /api/foods/search?q=
router.get('/search', async (req, res) => {
try {
export default function setupRoutes(app) {
app.get('/api/foods/search', async (req, res) => {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.status(400).json({ error: 'Search query must be at least 2 characters' });
if (!q) {
return res.json([]);
}
const params = new URLSearchParams({
search_terms: q,
search_simple: 1,
action: 'process',
json: 1,
page_size: 20,
fields: 'product_name,brands,nutriments,serving_size'
});
const response = await fetch(`${OPENFOODFACTS_API}?${params}`, {
headers: {
'User-Agent': 'MealTracker/1.0'
}
});
if (!response.ok) {
throw new Error(`OpenFoodFacts API error: ${response.status}`);
}
const data = await response.json();
const foods = (data.products || []).map(product => ({
name: product.product_name || 'Unknown',
brand: product.brands || null,
calories: product.nutriments?.['energy-kcal_100g'] ?? product.nutriments?.['energy-kcal'] ?? 0,
protein: product.nutriments?.proteins_100g ?? product.nutriments?.proteins ?? 0,
carbs: product.nutriments?.carbohydrates_100g ?? product.nutriments?.carbohydrates ?? 0,
fat: product.nutriments?.fat_100g ?? product.nutriments?.fat ?? 0,
serving_size: product.serving_size || '100g'
})).filter(f => f.name !== 'Unknown');
res.json(foods);
} catch (err) {
console.error('Food search error:', err);
res.status(500).json({ error: err.message });
}
});
export default router;
try {
const response = await axios.get(OPENFOODFACTS_API, {
params: {
search_terms: q,
search_simple: 1,
action: 'process',
json: 1,
page_size: 10
}
});
const products = response.data.products || [];
const foods = products.map(p => ({
name: p.product_name || p.product_name_en || 'Unknown',
calories: p.nutriments?.['energy-kcal_100g'] || 0,
protein: p.nutriments?.proteins_100g || 0,
carbs: p.nutriments?.carbohydrates_100g || 0,
fat: p.nutriments?.fat_100g || 0
})).filter(f => f.name !== 'Unknown' && f.calories > 0);
res.json(foods);
} catch (err) {
console.error(err);
res.status(503).json({ error: 'Food search unavailable' });
}
});
}

18
server/routes/stats.js Normal file
View File

@@ -0,0 +1,18 @@
import express from 'express';
const router = express.Router();
router.get('/goals', (req, res) => {
res.json({
calories: 2000,
protein: 150,
carbs: 250,
fat: 65
});
});
router.get('/streak', (req, res) => {
res.json({ currentStreak: 0, longestStreak: 0 });
});
export default router;

View File

@@ -1,26 +1,15 @@
import express from 'express';
import db from '../models/db.js';
const router = express.Router();
// GET /api/summary/daily?date=YYYY-MM-DD
router.get('/daily', (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date parameter is required (YYYY-MM-DD)' });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' });
}
const summary = db.getDailyTotals(date);
res.json(summary);
} catch (err) {
res.status(500).json({ error: err.message });
}
// Return empty summary for now
res.json({
total_calories: 0,
total_protein: 0,
total_carbs: 0,
total_fat: 0
});
});
export default router;

View File

@@ -0,0 +1,22 @@
const Database = require("better-sqlite3");
const db = new Database("/root/meal-tracker/data/meal-tracker.db");
// Add user_id to entries
db.exec("ALTER TABLE entries ADD COLUMN user_id INTEGER DEFAULT 1");
// Create users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
const bcrypt = require("bcryptjs");
const hash = bcrypt.hashSync("meal123", 10);
db.prepare("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)").run("rob", hash);
console.log("Database updated with users table - default user: rob / meal123");
db.close();