feat: add native meal planning signal api

This commit is contained in:
OpenClaw Bot
2026-05-11 23:08:59 +02:00
parent 4aa2db7c63
commit 7c118068c0
4 changed files with 401 additions and 0 deletions
+86
View File
@@ -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);
`,
},
];
/**
+2
View File
@@ -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);
+31
View File
@@ -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' },
+282
View File
@@ -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;