Meal Tracker - full feature set with auth, favorites, admin panel
This commit is contained in:
97
server/routes/admin.js
Normal file
97
server/routes/admin.js
Normal 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
65
server/routes/auth.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
18
server/routes/stats.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user