fix: support existing meal planning bridge tables

This commit is contained in:
OpenClaw Bot
2026-05-11 23:36:52 +02:00
parent ba534cb864
commit 1828bef8f1
3 changed files with 133 additions and 35 deletions
+45
View File
@@ -1437,6 +1437,51 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_kids_cookbooks_recipe ON kids_cookbooks(recipe_id);
`,
},
{
version: 40,
description: 'Harmonize existing meal planning bridge tables',
up(database) {
const columns = (table) => new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
const addColumn = (table, name, definition) => {
if (!columns(table).has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
};
addColumn('meal_cooking_rules', 'priority', "INTEGER NOT NULL DEFAULT 100");
addColumn('meal_cooking_rules', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
addColumn('recipe_family_preferences', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
addColumn('recipe_variation_meta', 'kid_suitable_confidence', "INTEGER NOT NULL DEFAULT 0");
addColumn('recipe_variation_meta', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
addColumn('planned_meal_cooks', 'planned_for_date', "TEXT");
addColumn('planned_meal_cooks', 'source_plan_id', "TEXT");
addColumn('planned_meal_cooks', 'created_by', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
database.exec(`UPDATE planned_meal_cooks SET planned_for_date = COALESCE(planned_for_date, meal_date) WHERE planned_for_date IS NULL AND meal_date IS NOT NULL`);
addColumn('meal_plan_feedback', 'plan_id', "TEXT");
addColumn('meal_plan_feedback', 'meal_id', "INTEGER REFERENCES meals(id) ON DELETE SET NULL");
addColumn('meal_plan_feedback', 'meal_type', "TEXT");
addColumn('meal_plan_feedback', 'action', "TEXT NOT NULL DEFAULT 'edit'");
addColumn('meal_plan_feedback', 'original_title', "TEXT");
addColumn('meal_plan_feedback', 'final_title', "TEXT");
addColumn('meal_plan_feedback', 'notes', "TEXT");
addColumn('meal_plan_feedback', 'user_id', "INTEGER REFERENCES users(id) ON DELETE SET NULL");
database.exec(`UPDATE meal_plan_feedback SET action = COALESCE(NULLIF(action, ''), type, 'edit') WHERE action IS NULL OR action = ''`);
addColumn('kids_cookbooks', 'content_json', "TEXT");
database.exec(`UPDATE kids_cookbooks SET content_json = COALESCE(content_json, payload) WHERE content_json IS NULL AND payload IS NOT NULL`);
database.exec(`
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);
`);
},
},
];
/**
+66 -25
View File
@@ -5,6 +5,7 @@
*/
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';
@@ -53,6 +54,30 @@ function handleError(res, err, context) {
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(`
@@ -80,7 +105,17 @@ router.put('/cooking-rules', (req, res) => {
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);
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();
@@ -127,7 +162,7 @@ router.put('/recipe-signals/:recipeId', (req, res) => {
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
currentUserId(req)
);
const data = db.get().prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId);
res.json({ data });
@@ -162,7 +197,7 @@ router.put('/variation-meta/:recipeId', (req, res) => {
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);
`).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'); }
@@ -195,16 +230,18 @@ router.put('/cook-assignments/:mealId', (req, res) => {
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 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) {
insertWithOptionalTextId(
'planned_meal_cooks',
['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'],
[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 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'); }
@@ -237,14 +274,16 @@ router.post('/feedback', (req, res) => {
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(
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, req.session.userId
);
const data = db.get().prepare('SELECT * FROM meal_plan_feedback WHERE id = ?').get(result.lastInsertRowid);
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'); }
});
@@ -270,11 +309,13 @@ router.post('/kids-cookbooks', (req, res) => {
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);
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'); }
});
+22 -10
View File
@@ -6,6 +6,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import crypto from 'node:crypto';
import * as db from '../db.js';
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
@@ -70,21 +71,32 @@ function validateCookUserId(raw) {
return { present: true, value: id, error: null };
}
function tableColumns(table) {
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
}
function saveCookAssignment(meal, cookUserId, sourcePlanId, createdBy) {
if (cookUserId === null) {
db.get().prepare('DELETE FROM planned_meal_cooks WHERE meal_id = ?').run(meal.id);
return;
}
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(meal.id, cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy);
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(cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, meal.id);
if (update.changes > 0) return;
const columns = tableColumns('planned_meal_cooks');
const insertColumns = ['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'];
const values = [meal.id, cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')];
if (columns.has('id')) {
insertColumns.unshift('id');
values.unshift(crypto.randomUUID());
}
const placeholders = insertColumns.map(() => '?').join(', ');
db.get().prepare(`INSERT INTO planned_meal_cooks (${insertColumns.join(', ')}) VALUES (${placeholders})`).run(...values);
}
function syncCookAssignmentSlot(meal) {