/** * Modul: Meal Planning * Zweck: Native Oikos API surface for Assist/Meal Plan Studio learning signals. * Dependencies: express, server/db.js */ import express from 'express'; import crypto from 'node:crypto'; import * as db from '../db.js'; import { createLogger } from '../logger.js'; import { str, num, date, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js'; import { generateGroceryList, scoreMealSuggestions } from '../services/meal-fit.js'; const log = createLogger('MealPlanning'); const router = express.Router(); const VALID_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']; const VALID_PREFERENCES = ['neutral', 'like', 'dislike', 'favorite']; const VALID_FEEDBACK = ['accept', 'reject', 'edit', 'swap', 'confirm', 'cookbook_save', 'cookbook_use']; function asBool(value) { return value === true || value === 1 || value === '1' ? 1 : 0; } function parseId(value, field = 'ID') { const id = Number(value); if (!Number.isInteger(id) || id <= 0) throw Object.assign(new Error(`${field} is invalid.`), { status: 400 }); return id; } function ensureUser(userId) { const id = parseId(userId, 'User ID'); const exists = db.get().prepare('SELECT id FROM users WHERE id = ?').get(id); if (!exists) throw Object.assign(new Error('User not found.'), { status: 400 }); return id; } function ensureRecipe(recipeId) { const id = parseId(recipeId, 'Recipe ID'); const exists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id); if (!exists) throw Object.assign(new Error('Recipe not found.'), { status: 400 }); return id; } function ensureMeal(mealId) { const id = parseId(mealId, 'Meal ID'); const exists = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(id); if (!exists) throw Object.assign(new Error('Meal not found.'), { status: 400 }); return id; } function handleError(res, err, context) { log.error(`${context}:`, err); res.status(err.status || 500).json({ error: err.status ? err.message : 'Internal server error.', code: err.status || 500 }); } 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 insertWithOptionalTextId(table, columns, values) { const available = tableColumns(table); const insertColumns = [...columns]; const insertValues = [...values]; if (available.has('id')) { const idInfo = db.get().prepare(`PRAGMA table_info(${table})`).all().find((row) => row.name === 'id'); if (String(idInfo?.type || '').toUpperCase() !== 'INTEGER') { insertColumns.unshift('id'); insertValues.unshift(crypto.randomUUID()); } } const placeholders = insertColumns.map(() => '?').join(', '); const result = db.get().prepare(`INSERT INTO ${table} (${insertColumns.join(', ')}) VALUES (${placeholders})`).run(...insertValues); return db.get().prepare(`SELECT * FROM ${table} WHERE rowid = ?`).get(result.lastInsertRowid); } router.get('/cooking-rules', (_req, res) => { try { const rows = db.get().prepare(` SELECT r.*, u.display_name AS cook_name, u.avatar_color AS cook_color FROM meal_cooking_rules r LEFT JOIN users u ON u.id = r.user_id ORDER BY r.weekday ASC, r.meal_type ASC, r.priority DESC, u.display_name COLLATE NOCASE ASC `).all(); res.json({ data: rows }); } catch (err) { handleError(res, err, 'GET /cooking-rules'); } }); router.put('/cooking-rules', (req, res) => { try { const rules = Array.isArray(req.body?.data) ? req.body.data : Array.isArray(req.body?.rules) ? req.body.rules : []; const insert = db.get().prepare(` INSERT INTO meal_cooking_rules (user_id, weekday, meal_type, priority, created_by) VALUES (?, ?, ?, ?, ?) `); db.transaction(() => { db.get().prepare('DELETE FROM meal_cooking_rules').run(); for (const rule of rules) { const userId = ensureUser(rule.user_id ?? rule.userId); const weekday = Number(rule.weekday); if (!VALID_WEEKDAYS.includes(weekday)) throw Object.assign(new Error('Weekday must be 0-6.'), { status: 400 }); const mealType = VALID_MEAL_TYPES.includes(rule.meal_type || rule.mealType || 'dinner') ? (rule.meal_type || rule.mealType || 'dinner') : 'dinner'; const priority = Number.isFinite(Number(rule.priority)) ? Number(rule.priority) : 100; if (tableColumns('meal_cooking_rules').has('id')) { const idInfo = db.get().prepare(`PRAGMA table_info(meal_cooking_rules)`).all().find((row) => row.name === 'id'); if (String(idInfo?.type || '').toUpperCase() !== 'INTEGER') { db.get().prepare(` INSERT INTO meal_cooking_rules (id, user_id, weekday, meal_type, priority, created_by) VALUES (?, ?, ?, ?, ?, ?) `).run(crypto.randomUUID(), userId, weekday, mealType, priority, currentUserId(req)); continue; } } insert.run(userId, weekday, mealType, priority, currentUserId(req)); } })(); const data = db.get().prepare('SELECT * FROM meal_cooking_rules ORDER BY weekday ASC, meal_type ASC, priority DESC').all(); res.json({ data }); } catch (err) { handleError(res, err, 'PUT /cooking-rules'); } }); router.get('/recipe-signals', (_req, res) => { try { const data = db.get().prepare(` SELECT p.*, r.title AS recipe_title, u.display_name AS user_name FROM recipe_family_preferences p LEFT JOIN recipes r ON r.id = p.recipe_id LEFT JOIN users u ON u.id = p.user_id ORDER BY r.title COLLATE NOCASE ASC, u.display_name COLLATE NOCASE ASC `).all(); res.json({ data }); } catch (err) { handleError(res, err, 'GET /recipe-signals'); } }); router.put('/recipe-signals/:recipeId', (req, res) => { try { const recipeId = ensureRecipe(req.params.recipeId); const userId = ensureUser(req.body.user_id ?? req.body.userId); const preference = VALID_PREFERENCES.includes(req.body.preference) ? req.body.preference : 'neutral'; db.get().prepare(` INSERT INTO recipe_family_preferences ( recipe_id, user_id, preference, can_cook, can_help_cook, will_eat_modified, adult_only, swap_in_count, swap_away_count, created_by, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ON CONFLICT(recipe_id, user_id) DO UPDATE SET preference = excluded.preference, can_cook = excluded.can_cook, can_help_cook = excluded.can_help_cook, will_eat_modified = excluded.will_eat_modified, adult_only = excluded.adult_only, swap_in_count = excluded.swap_in_count, swap_away_count = excluded.swap_away_count, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') `).run( recipeId, userId, preference, asBool(req.body.can_cook ?? req.body.canCook), asBool(req.body.can_help_cook ?? req.body.canHelpCook), asBool(req.body.will_eat_modified ?? req.body.willEatModified), asBool(req.body.adult_only ?? req.body.adultOnly), Number(req.body.swap_in_count ?? req.body.swapInCount ?? 0), Number(req.body.swap_away_count ?? req.body.swapAwayCount ?? 0), currentUserId(req) ); const data = db.get().prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId); res.json({ data }); } catch (err) { handleError(res, err, 'PUT /recipe-signals/:recipeId'); } }); router.get('/variation-meta', (_req, res) => { try { const data = db.get().prepare(` SELECT v.*, r.title AS recipe_title FROM recipe_variation_meta v LEFT JOIN recipes r ON r.id = v.recipe_id ORDER BY r.title COLLATE NOCASE ASC `).all(); res.json({ data }); } catch (err) { handleError(res, err, 'GET /variation-meta'); } }); router.put('/variation-meta/:recipeId', (req, res) => { try { const recipeId = ensureRecipe(req.params.recipeId); const protein = str(req.body.protein, 'Protein', { max: MAX_SHORT, required: false }); const style = str(req.body.style, 'Style', { max: MAX_SHORT, required: false }); const kidConfidence = Math.max(0, Math.min(100, Number(req.body.kid_suitable_confidence ?? req.body.kidSuitableConfidence ?? 0))); const errors = collectErrors([protein, style]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); db.get().prepare(` INSERT INTO recipe_variation_meta (recipe_id, protein, style, kid_suitable_confidence, created_by, updated_at) VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ON CONFLICT(recipe_id) DO UPDATE SET protein = excluded.protein, style = excluded.style, kid_suitable_confidence = excluded.kid_suitable_confidence, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') `).run(recipeId, protein.value, style.value, kidConfidence, currentUserId(req)); const data = db.get().prepare('SELECT * FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId); res.json({ data }); } catch (err) { handleError(res, err, 'PUT /variation-meta/:recipeId'); } }); router.get('/cook-assignments', (req, res) => { try { const from = req.query.from && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.from)) ? req.query.from : null; const to = req.query.to && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.to)) ? req.query.to : null; const where = from && to ? 'WHERE a.planned_for_date BETWEEN ? AND ?' : ''; const args = from && to ? [from, to] : []; const data = db.get().prepare(` SELECT a.*, m.title AS meal_title, u.display_name AS cook_name FROM planned_meal_cooks a LEFT JOIN meals m ON m.id = a.meal_id LEFT JOIN users u ON u.id = a.user_id ${where} ORDER BY a.planned_for_date ASC, a.meal_type ASC `).all(...args); res.json({ data }); } catch (err) { handleError(res, err, 'GET /cook-assignments'); } }); router.put('/cook-assignments/:mealId', (req, res) => { try { const mealId = ensureMeal(req.params.mealId); const userId = ensureUser(req.body.user_id ?? req.body.userId); const meal = db.get().prepare('SELECT date, meal_type, title FROM meals WHERE id = ?').get(mealId); const vDate = date(req.body.planned_for_date ?? req.body.plannedForDate ?? meal.date, 'Planned date', true); const mealType = VALID_MEAL_TYPES.includes(req.body.meal_type || req.body.mealType || meal.meal_type) ? (req.body.meal_type || req.body.mealType || meal.meal_type) : meal.meal_type; const errors = collectErrors([vDate]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); 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(userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, currentUserId(req), mealId); if (update.changes === 0) { const columns = ['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at']; const values = [mealId, userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, currentUserId(req), new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')]; const available = tableColumns('planned_meal_cooks'); if (available.has('meal_date')) { columns.push('meal_date'); values.push(vDate.value); } if (available.has('meal_title')) { columns.push('meal_title'); values.push(meal.title || null); } insertWithOptionalTextId('planned_meal_cooks', columns, values); } const data = db.get().prepare('SELECT * FROM planned_meal_cooks WHERE meal_id = ?').get(mealId); res.json({ data }); } catch (err) { handleError(res, err, 'PUT /cook-assignments/:mealId'); } }); router.post('/suggestions', (req, res) => { try { const meals = Array.isArray(req.body?.meals) ? req.body.meals : []; const selectedMeals = Array.isArray(req.body?.selectedMeals) ? req.body.selectedMeals : []; const context = { meals, dayContext: req.body?.dayContext || req.body?.day_context || {}, preferences: Array.isArray(req.body?.preferences) ? req.body.preferences : [], inventory: Array.isArray(req.body?.inventory) ? req.body.inventory : [], recentMeals: Array.isArray(req.body?.recentMeals) ? req.body.recentMeals : [], pantryStaples: Array.isArray(req.body?.pantryStaples) ? req.body.pantryStaples : [], today: req.body?.today, }; if (!meals.length) return res.status(400).json({ error: 'At least one meal is required.', code: 400 }); const suggestions = scoreMealSuggestions(context); const groceryList = selectedMeals.length ? generateGroceryList(selectedMeals, { inventory: context.inventory, pantryStaples: context.pantryStaples }) : null; res.json({ data: { suggestions, groceryList } }); } catch (err) { handleError(res, err, 'POST /suggestions'); } }); router.get('/feedback', (req, res) => { try { const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50))); const data = db.get().prepare(` SELECT f.*, r.title AS recipe_title, u.display_name AS user_name FROM meal_plan_feedback f LEFT JOIN recipes r ON r.id = f.recipe_id LEFT JOIN users u ON u.id = f.user_id ORDER BY f.created_at DESC, f.id DESC LIMIT ? `).all(limit); res.json({ data }); } catch (err) { handleError(res, err, 'GET /feedback'); } }); router.post('/feedback', (req, res) => { try { const action = oneOf(req.body.action, VALID_FEEDBACK, 'Action'); const slotDate = date(req.body.slot_date ?? req.body.slotDate, 'Slot date', false); const mealType = oneOf(req.body.meal_type ?? req.body.mealType, VALID_MEAL_TYPES, 'Meal type'); const originalTitle = str(req.body.original_title ?? req.body.originalTitle, 'Original title', { max: MAX_TITLE, required: false }); const finalTitle = str(req.body.final_title ?? req.body.finalTitle, 'Final title', { max: MAX_TITLE, required: false }); const notes = str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false }); const errors = collectErrors([action, slotDate, mealType, originalTitle, finalTitle, notes]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const recipeId = req.body.recipe_id || req.body.recipeId ? ensureRecipe(req.body.recipe_id ?? req.body.recipeId) : null; const mealId = req.body.meal_id || req.body.mealId ? ensureMeal(req.body.meal_id ?? req.body.mealId) : null; const columns = ['plan_id', 'meal_id', 'recipe_id', 'slot_date', 'meal_type', 'action', 'original_title', 'final_title', 'notes', 'user_id']; const values = [ req.body.plan_id ?? req.body.planId ?? null, mealId, recipeId, slotDate.value, mealType.value, action.value, originalTitle.value, finalTitle.value, notes.value, currentUserId(req), ]; if (tableColumns('meal_plan_feedback').has('type')) { columns.push('type'); values.push(action.value); } const data = insertWithOptionalTextId('meal_plan_feedback', columns, values); res.status(201).json({ data }); } catch (err) { handleError(res, err, 'POST /feedback'); } }); router.get('/kids-cookbooks', (_req, res) => { try { const data = db.get().prepare(` SELECT k.*, r.title AS recipe_title, u.display_name AS creator_name FROM kids_cookbooks k LEFT JOIN recipes r ON r.id = k.recipe_id LEFT JOIN users u ON u.id = k.created_by ORDER BY k.updated_at DESC, k.id DESC `).all().map((row) => ({ ...row, content: row.content_json ? JSON.parse(row.content_json) : null })); res.json({ data }); } catch (err) { handleError(res, err, 'GET /kids-cookbooks'); } }); router.post('/kids-cookbooks', (req, res) => { try { const recipeId = req.body.recipe_id || req.body.recipeId ? ensureRecipe(req.body.recipe_id ?? req.body.recipeId) : null; const title = str(req.body.title, 'Title', { max: MAX_TITLE }); const content = req.body.content && typeof req.body.content === 'object' ? req.body.content : null; const errors = collectErrors([title]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (!content) return res.status(400).json({ error: 'Content object is required.', code: 400 }); const columns = ['recipe_id', 'title', 'content_json', 'created_by']; const values = [recipeId, title.value, JSON.stringify(content), currentUserId(req)]; if (tableColumns('kids_cookbooks').has('payload')) { columns.push('payload'); values.push(JSON.stringify(content)); } const data = insertWithOptionalTextId('kids_cookbooks', columns, values); res.status(201).json({ data: { ...data, content: JSON.parse(data.content_json) } }); } catch (err) { handleError(res, err, 'POST /kids-cookbooks'); } }); export default router;