feat: structure meal planning taxonomy and favorites

This commit is contained in:
OpenClaw Bot
2026-05-12 17:15:31 +02:00
parent cef366cce4
commit 58a76ee02d
9 changed files with 442 additions and 20 deletions
+2
View File
@@ -22,6 +22,8 @@ router.get('/members', (req, res) => {
c.phone,
c.email,
b.birth_date,
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.preference = 'favorite') AS favorite_meal_count,
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.can_cook = 1) AS can_cook_meal_count,
u.created_at
FROM users u
LEFT JOIN contacts c ON c.family_user_id = u.id
+49 -5
View File
@@ -15,6 +15,9 @@ const log = createLogger('Meals');
const router = express.Router();
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
// --------------------------------------------------------
// Hilfsfunktionen
@@ -75,6 +78,21 @@ function currentUserId(req) {
return req.authUserId ?? req.session?.userId ?? null;
}
function normalizeEnum(value, allowed, fallback = null) {
const normalized = String(value || '').trim().toLowerCase();
return allowed.includes(normalized) ? normalized : fallback;
}
function validateLeftoverMealId(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: 'Leftover source meal ID is invalid.' };
const exists = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(id);
if (!exists) return { present: true, value: null, error: 'Leftover source meal not found.' };
return { present: true, value: id, error: null };
}
function tableColumns(table) {
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
}
@@ -224,7 +242,7 @@ router.get('/', (req, res) => {
/**
* POST /api/v1/meals
* Neue Mahlzeit anlegen.
* Body: { date, meal_type, title, notes?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
* Body: { date, meal_type, title, notes?, meal_category?, protein?, style?, leftover_from_meal_id?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
* Response: { data: Meal }
*/
router.post('/', (req, res) => {
@@ -238,12 +256,19 @@ router.post('/', (req, res) => {
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 leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
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 (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.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 });
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, vLeftoverFromMealId.value ? 'leftovers' : 'other');
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, vLeftoverFromMealId.value ? 'none' : 'mixed');
const style = normalizeEnum(req.body.style, VALID_STYLES, vLeftoverFromMealId.value ? 'leftovers' : 'family');
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 });
@@ -251,9 +276,9 @@ router.post('/', (req, res) => {
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));
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, source_plan_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, mealCategory, protein, style, vLeftoverFromMealId.value, vSourcePlanId.value, currentUserId(req));
const mealId = result.lastInsertRowid;
@@ -316,9 +341,12 @@ router.put('/:id', (req, res) => {
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 leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
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 (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.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 !== '') {
@@ -326,6 +354,12 @@ router.put('/:id', (req, res) => {
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
}
const mealCategory = req.body.meal_category !== undefined || req.body.mealCategory !== undefined
? normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, meal.meal_category || 'other')
: meal.meal_category;
const protein = req.body.protein !== undefined ? normalizeEnum(req.body.protein, VALID_PROTEINS, meal.protein || 'mixed') : meal.protein;
const style = req.body.style !== undefined ? normalizeEnum(req.body.style, VALID_STYLES, meal.style || 'family') : meal.style;
db.get().prepare(`
UPDATE meals
SET date = COALESCE(?, date),
@@ -333,7 +367,12 @@ router.put('/:id', (req, res) => {
title = COALESCE(?, title),
notes = ?,
recipe_url = ?,
recipe_id = ?
recipe_id = ?,
meal_category = ?,
protein = ?,
style = ?,
leftover_from_meal_id = ?,
source_plan_id = ?
WHERE id = ?
`).run(
req.body.date ?? null,
@@ -342,6 +381,11 @@ router.put('/:id', (req, res) => {
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,
mealCategory,
protein,
style,
vLeftoverFromMealId.present ? vLeftoverFromMealId.value : meal.leftover_from_meal_id,
req.body.source_plan_id !== undefined || req.body.sourcePlanId !== undefined ? vSourcePlanId.value : meal.source_plan_id,
id
);
+31 -5
View File
@@ -12,6 +12,22 @@ import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../midd
const log = createLogger('Recipes');
const router = express.Router();
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
function normalizeEnum(value, allowed, fallback = null) {
const normalized = String(value || '').trim().toLowerCase();
return allowed.includes(normalized) ? normalized : fallback;
}
function normalizeTags(value) {
const tags = Array.isArray(value)
? value
: String(value || '').split(',');
return tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean).slice(0, 12);
}
function loadRecipeWithIngredients(id) {
const recipe = db.get().prepare(`
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
@@ -75,11 +91,16 @@ router.post('/', (req, res) => {
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, 'other');
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, 'mixed');
const style = normalizeEnum(req.body.style, VALID_STYLES, 'family');
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
const recipeId = db.transaction(() => {
const result = db.get().prepare(`
INSERT INTO recipes (title, notes, recipe_url, created_by)
VALUES (?, ?, ?, ?)
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
INSERT INTO recipes (title, notes, recipe_url, meal_category, protein, style, tags_json, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, req.session.userId);
const rid = Number(result.lastInsertRowid);
const insertIng = db.get().prepare(`
@@ -122,12 +143,17 @@ router.put('/:id', (req, res) => {
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, existing.meal_category || 'other');
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, existing.protein || 'mixed');
const style = normalizeEnum(req.body.style, VALID_STYLES, existing.style || 'family');
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
db.transaction(() => {
db.get().prepare(`
UPDATE recipes
SET title = ?, notes = ?, recipe_url = ?
SET title = ?, notes = ?, recipe_url = ?, meal_category = ?, protein = ?, style = ?, tags_json = ?
WHERE id = ?
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, id);
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, id);
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);