diff --git a/server/db.js b/server/db.js index 5ebe69f..0444d51 100644 --- a/server/db.js +++ b/server/db.js @@ -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); + `); + }, + }, ]; /** diff --git a/server/routes/meal-planning.js b/server/routes/meal-planning.js index 1778992..3e799c8 100644 --- a/server/routes/meal-planning.js +++ b/server/routes/meal-planning.js @@ -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'); } }); diff --git a/server/routes/meals.js b/server/routes/meals.js index 8ddec35..15595d6 100644 --- a/server/routes/meals.js +++ b/server/routes/meals.js @@ -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) {