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
+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;