283 lines
14 KiB
JavaScript
283 lines
14 KiB
JavaScript
/**
|
|
* 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;
|