c344d59d5a
- server/routes/meals.js: vollständige REST-API (GET Woche, POST/PUT/DELETE Mahlzeit, POST/PATCH/DELETE Zutaten, GET Autocomplete-Suggestions, POST to-shopping-list, POST week-to-shopping-list) - public/pages/meals.js: Wochengitter (Mo–So × 4 Mahlzeit-Typen), Navigations-Buttons, CRUD-Modal mit Autocomplete, Zutaten-Verwaltung, Einkaufslisten-Transfer-Button - public/styles/meals.css: Wochengitter, Slot-Karten, Modal-Overlay, Zutaten-Zeilen, Transfer-Panel, Typ-Farben - test-meals.js: 22 Tests (CRUD, Wochensortierung, Constraint, CASCADE, Integration, Autocomplete, Wochenberechnung) - package.json: test:meals + Gesamt-Test-Suite erweitert - public/index.html: meals.css eingebunden Gesamt: 93 Tests bestanden (29 DB + 8 Dashboard + 17 Tasks + 17 Shopping + 22 Meals) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
471 lines
15 KiB
JavaScript
471 lines
15 KiB
JavaScript
/**
|
|
* Modul: Essensplan (Meals)
|
|
* Zweck: REST-API-Routen für Mahlzeiten, Zutaten und Einkaufslisten-Integration
|
|
* Abhängigkeiten: express, server/db.js, server/auth.js
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const db = require('../db');
|
|
|
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
// --------------------------------------------------------
|
|
// Hilfsfunktionen
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Gibt den ISO-Datumstring (YYYY-MM-DD) für den Montag einer Woche zurück.
|
|
* @param {string} dateStr - beliebiges Datum der Woche (YYYY-MM-DD)
|
|
*/
|
|
function weekStart(dateStr) {
|
|
const d = new Date(dateStr + 'T00:00:00Z');
|
|
const day = d.getUTCDay(); // 0 = So, 1 = Mo, …
|
|
const diff = (day === 0 ? -6 : 1 - day);
|
|
d.setUTCDate(d.getUTCDate() + diff);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Gibt den ISO-Datumstring für den Sonntag einer Woche zurück.
|
|
*/
|
|
function weekEnd(dateStr) {
|
|
const start = weekStart(dateStr);
|
|
const d = new Date(start + 'T00:00:00Z');
|
|
d.setUTCDate(d.getUTCDate() + 6);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Routen — Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* GET /api/v1/meals/suggestions
|
|
* Autocomplete für Mahlzeit-Titel aus der Historie.
|
|
* Query: ?q=<string>
|
|
* Response: { data: [{ title, meal_type }] }
|
|
*/
|
|
router.get('/suggestions', (req, res) => {
|
|
try {
|
|
const q = (req.query.q || '').trim();
|
|
if (!q) return res.json({ data: [] });
|
|
|
|
const rows = db.get().prepare(`
|
|
SELECT DISTINCT title, meal_type
|
|
FROM meals
|
|
WHERE title LIKE ? COLLATE NOCASE
|
|
ORDER BY title ASC
|
|
LIMIT 10
|
|
`).all(`${q}%`);
|
|
|
|
res.json({ data: rows });
|
|
} catch (err) {
|
|
console.error('[meals/suggestions]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Routen — Wochenübersicht
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* GET /api/v1/meals
|
|
* Alle Mahlzeiten einer Woche inkl. Zutaten.
|
|
* Query: ?week=YYYY-MM-DD (beliebiges Datum der gewünschten Woche; default: aktuelle Woche)
|
|
* Response: { data: Meal[], weekStart: string, weekEnd: string }
|
|
*
|
|
* Meal: { id, date, meal_type, title, notes, created_by, ingredients: Ingredient[] }
|
|
* Ingredient: { id, meal_id, name, quantity, on_shopping_list }
|
|
*/
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const refDate = req.query.week && DATE_RE.test(req.query.week)
|
|
? req.query.week
|
|
: new Date().toISOString().slice(0, 10);
|
|
|
|
const from = weekStart(refDate);
|
|
const to = weekEnd(refDate);
|
|
|
|
const meals = db.get().prepare(`
|
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
|
FROM meals m
|
|
LEFT JOIN users u ON u.id = m.created_by
|
|
WHERE m.date BETWEEN ? AND ?
|
|
ORDER BY m.date ASC,
|
|
CASE m.meal_type
|
|
WHEN 'breakfast' THEN 0
|
|
WHEN 'lunch' THEN 1
|
|
WHEN 'dinner' THEN 2
|
|
WHEN 'snack' THEN 3
|
|
ELSE 4
|
|
END ASC
|
|
`).all(from, to);
|
|
|
|
// Zutaten für alle Mahlzeiten in einer Abfrage holen
|
|
const mealIds = meals.map((m) => m.id);
|
|
let ingredientMap = {};
|
|
|
|
if (mealIds.length > 0) {
|
|
const placeholders = mealIds.map(() => '?').join(',');
|
|
const ingredients = db.get().prepare(`
|
|
SELECT * FROM meal_ingredients
|
|
WHERE meal_id IN (${placeholders})
|
|
ORDER BY id ASC
|
|
`).all(...mealIds);
|
|
|
|
for (const ing of ingredients) {
|
|
if (!ingredientMap[ing.meal_id]) ingredientMap[ing.meal_id] = [];
|
|
ingredientMap[ing.meal_id].push(ing);
|
|
}
|
|
}
|
|
|
|
const result = meals.map((m) => ({
|
|
...m,
|
|
ingredients: ingredientMap[m.id] || [],
|
|
}));
|
|
|
|
res.json({ data: result, weekStart: from, weekEnd: to });
|
|
} catch (err) {
|
|
console.error('[meals/GET /]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// CRUD — Mahlzeiten
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* POST /api/v1/meals
|
|
* Neue Mahlzeit anlegen.
|
|
* Body: { date, meal_type, title, notes?, ingredients?: [{ name, quantity? }] }
|
|
* Response: { data: Meal }
|
|
*/
|
|
router.post('/', (req, res) => {
|
|
try {
|
|
const { date, meal_type, title, notes = null, ingredients = [] } = req.body;
|
|
|
|
if (!date || !DATE_RE.test(date))
|
|
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
|
|
if (!meal_type || !VALID_MEAL_TYPES.includes(meal_type))
|
|
return res.status(400).json({ error: `meal_type muss einer von: ${VALID_MEAL_TYPES.join(', ')} sein`, code: 400 });
|
|
if (!title || !title.trim())
|
|
return res.status(400).json({ error: 'Titel ist erforderlich', code: 400 });
|
|
|
|
const meal = db.transaction(() => {
|
|
const result = db.get().prepare(`
|
|
INSERT INTO meals (date, meal_type, title, notes, created_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(date, meal_type, title.trim(), notes || null, req.session.userId);
|
|
|
|
const mealId = result.lastInsertRowid;
|
|
|
|
const insertIng = db.get().prepare(`
|
|
INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, ?, ?)
|
|
`);
|
|
|
|
for (const ing of ingredients) {
|
|
if (ing.name && ing.name.trim()) {
|
|
insertIng.run(mealId, ing.name.trim(), ing.quantity?.trim() || null);
|
|
}
|
|
}
|
|
|
|
return db.get().prepare(`
|
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
|
FROM meals m
|
|
LEFT JOIN users u ON u.id = m.created_by
|
|
WHERE m.id = ?
|
|
`).get(mealId);
|
|
})();
|
|
|
|
// Zutaten anhängen
|
|
const ings = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
|
).all(meal.id);
|
|
|
|
res.status(201).json({ data: { ...meal, ingredients: ings } });
|
|
} catch (err) {
|
|
console.error('[meals/POST /]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v1/meals/:id
|
|
* Mahlzeit bearbeiten (Titel, Notizen, Datum, Typ).
|
|
* Body: { date?, meal_type?, title?, notes? }
|
|
* Response: { data: Meal }
|
|
*/
|
|
router.put('/:id', (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
const meal = db.get().prepare('SELECT * FROM meals WHERE id = ?').get(id);
|
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
|
|
|
const { date, meal_type, title, notes } = req.body;
|
|
|
|
if (date !== undefined && !DATE_RE.test(date))
|
|
return res.status(400).json({ error: 'Ungültiges Datum', code: 400 });
|
|
if (meal_type !== undefined && !VALID_MEAL_TYPES.includes(meal_type))
|
|
return res.status(400).json({ error: 'Ungültiger meal_type', code: 400 });
|
|
|
|
db.get().prepare(`
|
|
UPDATE meals
|
|
SET date = COALESCE(?, date),
|
|
meal_type = COALESCE(?, meal_type),
|
|
title = COALESCE(?, title),
|
|
notes = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
date ?? null,
|
|
meal_type ?? null,
|
|
title?.trim() ?? null,
|
|
notes !== undefined ? (notes || null) : meal.notes,
|
|
id
|
|
);
|
|
|
|
const updated = db.get().prepare(`
|
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
|
FROM meals m LEFT JOIN users u ON u.id = m.created_by
|
|
WHERE m.id = ?
|
|
`).get(id);
|
|
|
|
const ings = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
|
).all(id);
|
|
|
|
res.json({ data: { ...updated, ingredients: ings } });
|
|
} catch (err) {
|
|
console.error('[meals/PUT /:id]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v1/meals/:id
|
|
* Mahlzeit löschen (Zutaten werden per CASCADE mitgelöscht).
|
|
* Response: 204 No Content
|
|
*/
|
|
router.delete('/:id', (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
const result = db.get().prepare('DELETE FROM meals WHERE id = ?').run(id);
|
|
if (result.changes === 0)
|
|
return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
|
res.status(204).end();
|
|
} catch (err) {
|
|
console.error('[meals/DELETE /:id]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// CRUD — Zutaten
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* POST /api/v1/meals/:id/ingredients
|
|
* Zutat zur Mahlzeit hinzufügen.
|
|
* Body: { name, quantity? }
|
|
* Response: { data: Ingredient }
|
|
*/
|
|
router.post('/:id/ingredients', (req, res) => {
|
|
try {
|
|
const mealId = parseInt(req.params.id, 10);
|
|
const meal = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(mealId);
|
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
|
|
|
const { name, quantity = null } = req.body;
|
|
if (!name || !name.trim())
|
|
return res.status(400).json({ error: 'Name ist erforderlich', code: 400 });
|
|
|
|
const result = db.get().prepare(`
|
|
INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, ?, ?)
|
|
`).run(mealId, name.trim(), quantity?.trim() || null);
|
|
|
|
const ing = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE id = ?'
|
|
).get(result.lastInsertRowid);
|
|
|
|
res.status(201).json({ data: ing });
|
|
} catch (err) {
|
|
console.error('[meals/POST /:id/ingredients]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/v1/meals/ingredients/:ingId
|
|
* Zutat bearbeiten (Name, Menge, on_shopping_list-Flag).
|
|
* Body: { name?, quantity?, on_shopping_list? }
|
|
* Response: { data: Ingredient }
|
|
*/
|
|
router.patch('/ingredients/:ingId', (req, res) => {
|
|
try {
|
|
const ingId = parseInt(req.params.ingId, 10);
|
|
const ing = db.get().prepare('SELECT * FROM meal_ingredients WHERE id = ?').get(ingId);
|
|
if (!ing) return res.status(404).json({ error: 'Zutat nicht gefunden', code: 404 });
|
|
|
|
const { name, quantity, on_shopping_list } = req.body;
|
|
|
|
db.get().prepare(`
|
|
UPDATE meal_ingredients
|
|
SET name = COALESCE(?, name),
|
|
quantity = ?,
|
|
on_shopping_list = COALESCE(?, on_shopping_list)
|
|
WHERE id = ?
|
|
`).run(
|
|
name?.trim() ?? null,
|
|
quantity !== undefined ? (quantity?.trim() || null) : ing.quantity,
|
|
on_shopping_list !== undefined ? (on_shopping_list ? 1 : 0) : null,
|
|
ingId
|
|
);
|
|
|
|
const updated = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE id = ?'
|
|
).get(ingId);
|
|
|
|
res.json({ data: updated });
|
|
} catch (err) {
|
|
console.error('[meals/PATCH /ingredients/:ingId]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v1/meals/ingredients/:ingId
|
|
* Zutat löschen.
|
|
* Response: 204 No Content
|
|
*/
|
|
router.delete('/ingredients/:ingId', (req, res) => {
|
|
try {
|
|
const ingId = parseInt(req.params.ingId, 10);
|
|
const result = db.get().prepare('DELETE FROM meal_ingredients WHERE id = ?').run(ingId);
|
|
if (result.changes === 0)
|
|
return res.status(404).json({ error: 'Zutat nicht gefunden', code: 404 });
|
|
res.status(204).end();
|
|
} catch (err) {
|
|
console.error('[meals/DELETE /ingredients/:ingId]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Integration: Zutaten → Einkaufsliste (Phase 2, Schritt 12)
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* POST /api/v1/meals/:id/to-shopping-list
|
|
* Alle noch nicht übertragenen Zutaten einer Mahlzeit auf eine Einkaufsliste übernehmen.
|
|
* Body: { listId: number, category?: string }
|
|
* Response: { data: { transferred: number } }
|
|
*/
|
|
router.post('/:id/to-shopping-list', (req, res) => {
|
|
try {
|
|
const mealId = parseInt(req.params.id, 10);
|
|
const meal = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(mealId);
|
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
|
|
|
const { listId, category = 'Sonstiges' } = req.body;
|
|
if (!listId)
|
|
return res.status(400).json({ error: 'listId ist erforderlich', code: 400 });
|
|
|
|
const list = db.get().prepare('SELECT id FROM shopping_lists WHERE id = ?').get(listId);
|
|
if (!list) return res.status(404).json({ error: 'Einkaufsliste nicht gefunden', code: 404 });
|
|
|
|
const ingredients = db.get().prepare(`
|
|
SELECT * FROM meal_ingredients
|
|
WHERE meal_id = ? AND on_shopping_list = 0
|
|
`).all(mealId);
|
|
|
|
if (ingredients.length === 0)
|
|
return res.json({ data: { transferred: 0 } });
|
|
|
|
const transferred = db.transaction(() => {
|
|
const insertItem = db.get().prepare(`
|
|
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
const markDone = db.get().prepare(`
|
|
UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?
|
|
`);
|
|
|
|
let count = 0;
|
|
for (const ing of ingredients) {
|
|
insertItem.run(listId, ing.name, ing.quantity, category, mealId);
|
|
markDone.run(ing.id);
|
|
count++;
|
|
}
|
|
return count;
|
|
})();
|
|
|
|
res.json({ data: { transferred } });
|
|
} catch (err) {
|
|
console.error('[meals/POST /:id/to-shopping-list]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v1/meals/week-to-shopping-list
|
|
* Alle noch nicht übertragenen Zutaten einer ganzen Woche auf eine Einkaufsliste übernehmen.
|
|
* Body: { listId, week: YYYY-MM-DD, category? }
|
|
* Response: { data: { transferred: number } }
|
|
*/
|
|
router.post('/week-to-shopping-list', (req, res) => {
|
|
try {
|
|
const { listId, week, category = 'Sonstiges' } = req.body;
|
|
|
|
if (!listId)
|
|
return res.status(400).json({ error: 'listId ist erforderlich', code: 400 });
|
|
if (!week || !DATE_RE.test(week))
|
|
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
|
|
|
|
const list = db.get().prepare('SELECT id FROM shopping_lists WHERE id = ?').get(listId);
|
|
if (!list) return res.status(404).json({ error: 'Einkaufsliste nicht gefunden', code: 404 });
|
|
|
|
const from = weekStart(week);
|
|
const to = weekEnd(week);
|
|
|
|
const ingredients = db.get().prepare(`
|
|
SELECT mi.* FROM meal_ingredients mi
|
|
JOIN meals m ON m.id = mi.meal_id
|
|
WHERE m.date BETWEEN ? AND ?
|
|
AND mi.on_shopping_list = 0
|
|
`).all(from, to);
|
|
|
|
if (ingredients.length === 0)
|
|
return res.json({ data: { transferred: 0 } });
|
|
|
|
const transferred = db.transaction(() => {
|
|
const insertItem = db.get().prepare(`
|
|
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
const markDone = db.get().prepare(`
|
|
UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?
|
|
`);
|
|
|
|
let count = 0;
|
|
for (const ing of ingredients) {
|
|
insertItem.run(listId, ing.name, ing.quantity, category, ing.meal_id);
|
|
markDone.run(ing.id);
|
|
count++;
|
|
}
|
|
return count;
|
|
})();
|
|
|
|
res.json({ data: { transferred } });
|
|
} catch (err) {
|
|
console.error('[meals/POST /week-to-shopping-list]', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|