596 lines
22 KiB
JavaScript
596 lines
22 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
|
|
*/
|
|
|
|
import { createLogger } from '../logger.js';
|
|
import express from 'express';
|
|
import crypto from 'node:crypto';
|
|
import * as db from '../db.js';
|
|
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
|
|
|
const log = createLogger('Meals');
|
|
|
|
const router = express.Router();
|
|
|
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
|
|
|
// --------------------------------------------------------
|
|
// 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);
|
|
}
|
|
|
|
function loadCookAssignments(mealIds) {
|
|
if (!mealIds.length) return {};
|
|
const placeholders = mealIds.map(() => '?').join(',');
|
|
const rows = db.get().prepare(`
|
|
SELECT a.*, u.display_name AS cook_name, u.avatar_color AS cook_color
|
|
FROM planned_meal_cooks a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
WHERE a.meal_id IN (${placeholders})
|
|
`).all(...mealIds);
|
|
return Object.fromEntries(rows.map((row) => [row.meal_id, row]));
|
|
}
|
|
|
|
function attachCookAssignment(meal, cookMap) {
|
|
return {
|
|
...meal,
|
|
cook_assignment: cookMap[meal.id] || null,
|
|
};
|
|
}
|
|
|
|
function validateCookUserId(raw) {
|
|
if (raw === undefined) return { present: false, value: null, error: null };
|
|
if (raw === null || raw === '') return { present: true, value: null, error: null };
|
|
const id = Number(raw);
|
|
if (!Number.isInteger(id) || id <= 0) return { present: true, value: null, error: 'Cook user ID is invalid.' };
|
|
const exists = db.get().prepare('SELECT id FROM users WHERE id = ?').get(id);
|
|
if (!exists) return { present: true, value: null, error: 'Cook user not found.' };
|
|
return { present: true, value: id, error: null };
|
|
}
|
|
|
|
function currentUserId(req) {
|
|
return req.authUserId ?? req.session?.userId ?? null;
|
|
}
|
|
|
|
function tableColumns(table) {
|
|
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
|
}
|
|
|
|
function saveCookAssignment(meal, cookUserId, sourcePlanId, createdBy) {
|
|
if (cookUserId === null) {
|
|
db.get().prepare('DELETE FROM planned_meal_cooks WHERE meal_id = ?').run(meal.id);
|
|
return;
|
|
}
|
|
|
|
const update = db.get().prepare(`
|
|
UPDATE planned_meal_cooks
|
|
SET user_id = ?, planned_for_date = ?, meal_type = ?, source_plan_id = ?, created_by = COALESCE(created_by, ?), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE meal_id = ?
|
|
`).run(cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, meal.id);
|
|
if (update.changes > 0) return;
|
|
|
|
const columns = tableColumns('planned_meal_cooks');
|
|
const insertColumns = ['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'];
|
|
const values = [meal.id, cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')];
|
|
if (columns.has('meal_date')) {
|
|
insertColumns.push('meal_date');
|
|
values.push(meal.date);
|
|
}
|
|
if (columns.has('meal_title')) {
|
|
insertColumns.push('meal_title');
|
|
values.push(meal.title || null);
|
|
}
|
|
if (columns.has('id')) {
|
|
insertColumns.unshift('id');
|
|
values.unshift(crypto.randomUUID());
|
|
}
|
|
const placeholders = insertColumns.map(() => '?').join(', ');
|
|
db.get().prepare(`INSERT INTO planned_meal_cooks (${insertColumns.join(', ')}) VALUES (${placeholders})`).run(...values);
|
|
}
|
|
|
|
function syncCookAssignmentSlot(meal) {
|
|
db.get().prepare(`
|
|
UPDATE planned_meal_cooks
|
|
SET planned_for_date = ?, meal_type = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE meal_id = ?
|
|
`).run(meal.date, meal.meal_type, meal.id);
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// 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) {
|
|
log.error('', 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 cookMap = loadCookAssignments(mealIds);
|
|
const result = meals.map((m) => attachCookAssignment({
|
|
...m,
|
|
ingredients: ingredientMap[m.id] || [],
|
|
}, cookMap));
|
|
|
|
res.json({ data: result, weekStart: from, weekEnd: to });
|
|
} catch (err) {
|
|
log.error('', 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?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
|
|
* Response: { data: Meal }
|
|
*/
|
|
router.post('/', (req, res) => {
|
|
try {
|
|
const { ingredients = [] } = req.body;
|
|
const vDate = date(req.body.date, 'Datum', true);
|
|
const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ');
|
|
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
|
|
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
|
|
const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
|
|
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
|
|
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
|
const vCookUserId = validateCookUserId(cookUserRaw);
|
|
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
|
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId, vSourcePlanId]);
|
|
if (vCookUserId.error) errors.push(vCookUserId.error);
|
|
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
|
|
|
if (vRecipeId.value !== null) {
|
|
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
|
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
|
}
|
|
|
|
const meal = db.transaction(() => {
|
|
const result = db.get().prepare(`
|
|
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, currentUserId(req));
|
|
|
|
const mealId = result.lastInsertRowid;
|
|
|
|
const insertIng = db.get().prepare(`
|
|
INSERT INTO meal_ingredients (meal_id, name, quantity, category) VALUES (?, ?, ?, ?)
|
|
`);
|
|
|
|
for (const ing of ingredients) {
|
|
const name = String(ing.name || '').trim().slice(0, MAX_TITLE);
|
|
const qty = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null;
|
|
const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges';
|
|
if (name) insertIng.run(mealId, name, qty, category);
|
|
}
|
|
|
|
const createdMeal = 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);
|
|
|
|
if (vCookUserId.present && vCookUserId.value !== null) {
|
|
saveCookAssignment(createdMeal, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
|
}
|
|
|
|
return createdMeal;
|
|
});
|
|
|
|
// Zutaten anhängen
|
|
const ings = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
|
).all(meal.id);
|
|
|
|
const cookMap = loadCookAssignments([meal.id]);
|
|
res.status(201).json({ data: attachCookAssignment({ ...meal, ingredients: ings }, cookMap) });
|
|
} catch (err) {
|
|
log.error('', 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 checks = [];
|
|
if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum'));
|
|
if (req.body.meal_type !== undefined) checks.push(oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ'));
|
|
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
|
|
if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
|
|
if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }));
|
|
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { required: false }));
|
|
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
|
const vCookUserId = validateCookUserId(cookUserRaw);
|
|
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
|
const errors = collectErrors([...checks, vSourcePlanId]);
|
|
if (vCookUserId.error) errors.push(vCookUserId.error);
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
|
|
|
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
|
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(req.body.recipe_id);
|
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
|
}
|
|
|
|
db.get().prepare(`
|
|
UPDATE meals
|
|
SET date = COALESCE(?, date),
|
|
meal_type = COALESCE(?, meal_type),
|
|
title = COALESCE(?, title),
|
|
notes = ?,
|
|
recipe_url = ?,
|
|
recipe_id = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
req.body.date ?? null,
|
|
req.body.meal_type ?? null,
|
|
req.body.title?.trim() ?? null,
|
|
req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
|
|
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
|
|
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
|
|
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);
|
|
|
|
if (vCookUserId.present) {
|
|
saveCookAssignment(updated, vCookUserId.value, vSourcePlanId.value, currentUserId(req));
|
|
} else {
|
|
syncCookAssignmentSlot(updated);
|
|
}
|
|
|
|
const ings = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
|
).all(id);
|
|
const cookMap = loadCookAssignments([id]);
|
|
|
|
res.json({ data: attachCookAssignment({ ...updated, ingredients: ings }, cookMap) });
|
|
} catch (err) {
|
|
log.error('', 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) {
|
|
log.error('', 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, category = 'Sonstiges' } = 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, category) VALUES (?, ?, ?, ?)
|
|
`).run(mealId, name.trim(), quantity?.trim() || null, String(category || '').trim() || 'Sonstiges');
|
|
|
|
const ing = db.get().prepare(
|
|
'SELECT * FROM meal_ingredients WHERE id = ?'
|
|
).get(result.lastInsertRowid);
|
|
|
|
res.status(201).json({ data: ing });
|
|
} catch (err) {
|
|
log.error('', 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, category } = req.body;
|
|
|
|
db.get().prepare(`
|
|
UPDATE meal_ingredients
|
|
SET name = COALESCE(?, name),
|
|
quantity = ?,
|
|
category = COALESCE(?, category),
|
|
on_shopping_list = COALESCE(?, on_shopping_list)
|
|
WHERE id = ?
|
|
`).run(
|
|
name?.trim() ?? null,
|
|
quantity !== undefined ? (quantity?.trim() || null) : ing.quantity,
|
|
category !== undefined ? (String(category || '').trim() || 'Sonstiges') : null,
|
|
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) {
|
|
log.error('', 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) {
|
|
log.error('', 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 } = 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, ing.category || 'Sonstiges', mealId);
|
|
markDone.run(ing.id);
|
|
count++;
|
|
}
|
|
return count;
|
|
});
|
|
|
|
res.json({ data: { transferred } });
|
|
} catch (err) {
|
|
log.error('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 } = 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, ing.category || 'Sonstiges', ing.meal_id);
|
|
markDone.run(ing.id);
|
|
count++;
|
|
}
|
|
return count;
|
|
});
|
|
|
|
res.json({ data: { transferred } });
|
|
} catch (err) {
|
|
log.error('POST /week-to-shopping-list', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
export default router;
|