diff --git a/server/db.js b/server/db.js index 7d324be..5ebe69f 100644 --- a/server/db.js +++ b/server/db.js @@ -1351,6 +1351,92 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_calendar_attachment_document ON calendar_events(attachment_document_id); `, }, + { + version: 39, + description: 'Native meal planning signals', + up: ` + CREATE TABLE IF NOT EXISTS meal_cooking_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + weekday INTEGER NOT NULL CHECK(weekday BETWEEN 0 AND 6), + meal_type TEXT NOT NULL DEFAULT 'dinner' CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + priority INTEGER NOT NULL DEFAULT 100, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(user_id, weekday, meal_type) + ); + + CREATE TABLE IF NOT EXISTS recipe_family_preferences ( + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + preference TEXT NOT NULL DEFAULT 'neutral' CHECK(preference IN ('neutral', 'like', 'dislike', 'favorite')), + can_cook INTEGER NOT NULL DEFAULT 0, + can_help_cook INTEGER NOT NULL DEFAULT 0, + will_eat_modified INTEGER NOT NULL DEFAULT 0, + adult_only INTEGER NOT NULL DEFAULT 0, + swap_in_count INTEGER NOT NULL DEFAULT 0, + swap_away_count INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY(recipe_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS recipe_variation_meta ( + recipe_id INTEGER PRIMARY KEY REFERENCES recipes(id) ON DELETE CASCADE, + protein TEXT, + style TEXT, + kid_suitable_confidence INTEGER NOT NULL DEFAULT 0 CHECK(kid_suitable_confidence BETWEEN 0 AND 100), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS planned_meal_cooks ( + meal_id INTEGER PRIMARY KEY REFERENCES meals(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + planned_for_date TEXT NOT NULL, + meal_type TEXT NOT NULL DEFAULT 'dinner' CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + source_plan_id TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS meal_plan_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id TEXT, + meal_id INTEGER REFERENCES meals(id) ON DELETE SET NULL, + recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL, + slot_date TEXT, + meal_type TEXT CHECK(meal_type IS NULL OR meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + action TEXT NOT NULL CHECK(action IN ('accept', 'reject', 'edit', 'swap', 'confirm', 'cookbook_save', 'cookbook_use')), + original_title TEXT, + final_title TEXT, + notes TEXT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS kids_cookbooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL, + title TEXT NOT NULL, + content_json TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_meal_cooking_rules_weekday ON meal_cooking_rules(weekday, meal_type); + CREATE INDEX IF NOT EXISTS idx_recipe_family_preferences_user ON recipe_family_preferences(user_id, preference); + CREATE INDEX IF NOT EXISTS idx_planned_meal_cooks_date ON planned_meal_cooks(planned_for_date, meal_type); + CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_recipe ON meal_plan_feedback(recipe_id, action); + CREATE INDEX IF NOT EXISTS idx_meal_plan_feedback_created ON meal_plan_feedback(created_at); + CREATE INDEX IF NOT EXISTS idx_kids_cookbooks_recipe ON kids_cookbooks(recipe_id); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 9f04c18..74abe8b 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import dashboardRouter from './routes/dashboard.js'; import tasksRouter from './routes/tasks.js'; import shoppingRouter from './routes/shopping.js'; import mealsRouter from './routes/meals.js'; +import mealPlanningRouter from './routes/meal-planning.js'; import recipesRouter from './routes/recipes.js'; import calendarRouter from './routes/calendar.js'; import notesRouter from './routes/notes.js'; @@ -228,6 +229,7 @@ app.use('/api/v1/dashboard', dashboardRouter); app.use('/api/v1/tasks', tasksRouter); app.use('/api/v1/shopping', shoppingRouter); app.use('/api/v1/meals', mealsRouter); +app.use('/api/v1/meal-planning', mealPlanningRouter); app.use('/api/v1/recipes', recipesRouter); app.use('/api/v1/calendar', calendarRouter); app.use('/api/v1/notes', notesRouter); diff --git a/server/openapi.js b/server/openapi.js index f7c46f6..66187ff 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -390,6 +390,36 @@ function buildPaths() { '/api/v1/meals/week-to-shopping-list': { post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }), }, + '/api/v1/meal-planning/cooking-rules': { + get: op({ summary: 'List recurring meal cook rules', tag: 'Meal Planning' }), + put: op({ summary: 'Replace recurring meal cook rules', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meal-planning/recipe-signals': { + get: op({ summary: 'List family recipe preference/capability signals', tag: 'Meal Planning' }), + }, + '/api/v1/meal-planning/recipe-signals/{recipeId}': { + put: op({ summary: 'Upsert family recipe preference/capability signal', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meal-planning/variation-meta': { + get: op({ summary: 'List recipe variation metadata', tag: 'Meal Planning' }), + }, + '/api/v1/meal-planning/variation-meta/{recipeId}': { + put: op({ summary: 'Upsert recipe variation metadata', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meal-planning/cook-assignments': { + get: op({ summary: 'List planned meal cook assignments', tag: 'Meal Planning' }), + }, + '/api/v1/meal-planning/cook-assignments/{mealId}': { + put: op({ summary: 'Upsert planned meal cook assignment', tag: 'Meal Planning', params: [idParam('mealId', 'Meal ID')], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meal-planning/feedback': { + get: op({ summary: 'List meal planning feedback events', tag: 'Meal Planning' }), + post: op({ summary: 'Record meal planning feedback event', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meal-planning/kids-cookbooks': { + get: op({ summary: 'List saved kids cookbooks', tag: 'Meal Planning' }), + post: op({ summary: 'Save kids cookbook', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }), + }, '/api/v1/recipes': { get: op({ summary: 'List recipes', tag: 'Recipes' }), post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }), @@ -663,6 +693,7 @@ function buildOpenApiSpec(req, appVersion) { { name: 'Tasks' }, { name: 'Shopping' }, { name: 'Meals' }, + { name: 'Meal Planning' }, { name: 'Recipes' }, { name: 'Calendar' }, { name: 'Notes' }, diff --git a/server/routes/meal-planning.js b/server/routes/meal-planning.js new file mode 100644 index 0000000..1778992 --- /dev/null +++ b/server/routes/meal-planning.js @@ -0,0 +1,282 @@ +/** + * 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 * 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'; + +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 }); +} + +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; + insert.run(userId, weekday, mealType, priority, req.session.userId); + } + })(); + 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), + req.session.userId + ); + 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, req.session.userId); + 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 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 }); + db.get().prepare(` + INSERT INTO planned_meal_cooks (meal_id, user_id, planned_for_date, meal_type, source_plan_id, created_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(meal_id) DO UPDATE SET + user_id = excluded.user_id, + planned_for_date = excluded.planned_for_date, + meal_type = excluded.meal_type, + source_plan_id = excluded.source_plan_id, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + `).run(mealId, userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, req.session.userId); + 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.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 result = db.get().prepare(` + INSERT INTO meal_plan_feedback (plan_id, meal_id, recipe_id, slot_date, meal_type, action, original_title, final_title, notes, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + req.body.plan_id ?? req.body.planId ?? null, mealId, recipeId, slotDate.value, mealType.value, + action.value, originalTitle.value, finalTitle.value, notes.value, req.session.userId + ); + const data = db.get().prepare('SELECT * FROM meal_plan_feedback WHERE id = ?').get(result.lastInsertRowid); + 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 result = db.get().prepare(` + INSERT INTO kids_cookbooks (recipe_id, title, content_json, created_by) + VALUES (?, ?, ?, ?) + `).run(recipeId, title.value, JSON.stringify(content), req.session.userId); + const data = db.get().prepare('SELECT * FROM kids_cookbooks WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ data: { ...data, content: JSON.parse(data.content_json) } }); + } catch (err) { handleError(res, err, 'POST /kids-cookbooks'); } +}); + +export default router;