fix: support existing meal planning bridge tables
This commit is contained in:
@@ -1437,6 +1437,51 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_kids_cookbooks_recipe ON kids_cookbooks(recipe_id);
|
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);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import { str, num, date, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.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 });
|
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) => {
|
router.get('/cooking-rules', (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = db.get().prepare(`
|
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 });
|
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 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;
|
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();
|
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),
|
asBool(req.body.adult_only ?? req.body.adultOnly),
|
||||||
Number(req.body.swap_in_count ?? req.body.swapInCount ?? 0),
|
Number(req.body.swap_in_count ?? req.body.swapInCount ?? 0),
|
||||||
Number(req.body.swap_away_count ?? req.body.swapAwayCount ?? 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);
|
const data = db.get().prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId);
|
||||||
res.json({ data });
|
res.json({ data });
|
||||||
@@ -162,7 +197,7 @@ router.put('/variation-meta/:recipeId', (req, res) => {
|
|||||||
style = excluded.style,
|
style = excluded.style,
|
||||||
kid_suitable_confidence = excluded.kid_suitable_confidence,
|
kid_suitable_confidence = excluded.kid_suitable_confidence,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
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);
|
const data = db.get().prepare('SELECT * FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId);
|
||||||
res.json({ data });
|
res.json({ data });
|
||||||
} catch (err) { handleError(res, err, 'PUT /variation-meta/:recipeId'); }
|
} 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 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]);
|
const errors = collectErrors([vDate]);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
db.get().prepare(`
|
const update = db.get().prepare(`
|
||||||
INSERT INTO planned_meal_cooks (meal_id, user_id, planned_for_date, meal_type, source_plan_id, created_by, updated_at)
|
UPDATE planned_meal_cooks
|
||||||
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
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')
|
||||||
ON CONFLICT(meal_id) DO UPDATE SET
|
WHERE meal_id = ?
|
||||||
user_id = excluded.user_id,
|
`).run(userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, currentUserId(req), mealId);
|
||||||
planned_for_date = excluded.planned_for_date,
|
if (update.changes === 0) {
|
||||||
meal_type = excluded.meal_type,
|
insertWithOptionalTextId(
|
||||||
source_plan_id = excluded.source_plan_id,
|
'planned_meal_cooks',
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
['meal_id', 'user_id', 'planned_for_date', 'meal_type', 'source_plan_id', 'created_by', 'updated_at'],
|
||||||
`).run(mealId, userId, vDate.value, mealType, req.body.source_plan_id ?? req.body.sourcePlanId ?? null, req.session.userId);
|
[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);
|
const data = db.get().prepare('SELECT * FROM planned_meal_cooks WHERE meal_id = ?').get(mealId);
|
||||||
res.json({ data });
|
res.json({ data });
|
||||||
} catch (err) { handleError(res, err, 'PUT /cook-assignments/:mealId'); }
|
} 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 });
|
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 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 mealId = req.body.meal_id || req.body.mealId ? ensureMeal(req.body.meal_id ?? req.body.mealId) : null;
|
||||||
const result = db.get().prepare(`
|
const columns = ['plan_id', 'meal_id', 'recipe_id', 'slot_date', 'meal_type', 'action', 'original_title', 'final_title', 'notes', 'user_id'];
|
||||||
INSERT INTO meal_plan_feedback (plan_id, meal_id, recipe_id, slot_date, meal_type, action, original_title, final_title, notes, user_id)
|
const values = [
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
req.body.plan_id ?? req.body.planId ?? null, mealId, recipeId, slotDate.value, mealType.value,
|
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
|
action.value, originalTitle.value, finalTitle.value, notes.value, currentUserId(req),
|
||||||
);
|
];
|
||||||
const data = db.get().prepare('SELECT * FROM meal_plan_feedback WHERE id = ?').get(result.lastInsertRowid);
|
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 });
|
res.status(201).json({ data });
|
||||||
} catch (err) { handleError(res, err, 'POST /feedback'); }
|
} catch (err) { handleError(res, err, 'POST /feedback'); }
|
||||||
});
|
});
|
||||||
@@ -270,11 +309,13 @@ router.post('/kids-cookbooks', (req, res) => {
|
|||||||
const errors = collectErrors([title]);
|
const errors = collectErrors([title]);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
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 });
|
if (!content) return res.status(400).json({ error: 'Content object is required.', code: 400 });
|
||||||
const result = db.get().prepare(`
|
const columns = ['recipe_id', 'title', 'content_json', 'created_by'];
|
||||||
INSERT INTO kids_cookbooks (recipe_id, title, content_json, created_by)
|
const values = [recipeId, title.value, JSON.stringify(content), currentUserId(req)];
|
||||||
VALUES (?, ?, ?, ?)
|
if (tableColumns('kids_cookbooks').has('payload')) {
|
||||||
`).run(recipeId, title.value, JSON.stringify(content), req.session.userId);
|
columns.push('payload');
|
||||||
const data = db.get().prepare('SELECT * FROM kids_cookbooks WHERE id = ?').get(result.lastInsertRowid);
|
values.push(JSON.stringify(content));
|
||||||
|
}
|
||||||
|
const data = insertWithOptionalTextId('kids_cookbooks', columns, values);
|
||||||
res.status(201).json({ data: { ...data, content: JSON.parse(data.content_json) } });
|
res.status(201).json({ data: { ...data, content: JSON.parse(data.content_json) } });
|
||||||
} catch (err) { handleError(res, err, 'POST /kids-cookbooks'); }
|
} catch (err) { handleError(res, err, 'POST /kids-cookbooks'); }
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-10
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.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 };
|
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) {
|
function saveCookAssignment(meal, cookUserId, sourcePlanId, createdBy) {
|
||||||
if (cookUserId === null) {
|
if (cookUserId === null) {
|
||||||
db.get().prepare('DELETE FROM planned_meal_cooks WHERE meal_id = ?').run(meal.id);
|
db.get().prepare('DELETE FROM planned_meal_cooks WHERE meal_id = ?').run(meal.id);
|
||||||
return;
|
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)
|
const update = db.get().prepare(`
|
||||||
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
UPDATE planned_meal_cooks
|
||||||
ON CONFLICT(meal_id) DO UPDATE SET
|
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')
|
||||||
user_id = excluded.user_id,
|
WHERE meal_id = ?
|
||||||
planned_for_date = excluded.planned_for_date,
|
`).run(cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy, meal.id);
|
||||||
meal_type = excluded.meal_type,
|
if (update.changes > 0) return;
|
||||||
source_plan_id = excluded.source_plan_id,
|
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
const columns = tableColumns('planned_meal_cooks');
|
||||||
`).run(meal.id, cookUserId, meal.date, meal.meal_type, sourcePlanId || null, createdBy);
|
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) {
|
function syncCookAssignmentSlot(meal) {
|
||||||
|
|||||||
Reference in New Issue
Block a user