feat: add recipes module with CRUD functionality and integrate with meals

- Implemented new recipes page with UI for managing recipes.
- Added REST API routes for recipes including create, read, update, and delete operations.
- Introduced database schema for recipes and recipe ingredients.
- Updated meals to link with recipes, allowing meals to reference specific recipes.
- Enhanced validation for recipe-related fields in meals.
- Added styles for the recipes page and components.
This commit is contained in:
Serhiy Bobrov
2026-04-21 13:43:42 +03:00
committed by Ulas Kalayci
parent 41467a84b6
commit 0b54fe255b
25 changed files with 1421 additions and 48 deletions
+35
View File
@@ -232,6 +232,41 @@ const MIGRATIONS_SQL = {
WHERE subscription_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
`,
12: `
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
notes TEXT,
recipe_url TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
category TEXT NOT NULL DEFAULT 'Sonstiges',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_recipes_title ON recipes(title);
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
CREATE TRIGGER IF NOT EXISTS trg_recipes_updated_at
AFTER UPDATE ON recipes FOR EACH ROW
BEGIN UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE TRIGGER IF NOT EXISTS trg_recipe_ingredients_updated_at
AFTER UPDATE ON recipe_ingredients FOR EACH ROW
BEGIN UPDATE recipe_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
`,
};
export { MIGRATIONS_SQL };
+39
View File
@@ -484,6 +484,45 @@ const MIGRATIONS = [
ON calendar_events (subscription_id, external_calendar_id);
`,
},
{
version: 13,
description: 'Rezepte-Tabelle und Mahlzeiten-Verknuepfung',
up: `
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
notes TEXT,
recipe_url TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
category TEXT NOT NULL DEFAULT 'Sonstiges',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_recipes_title ON recipes(title);
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
CREATE TRIGGER IF NOT EXISTS trg_recipes_updated_at
AFTER UPDATE ON recipes FOR EACH ROW
BEGIN UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE TRIGGER IF NOT EXISTS trg_recipe_ingredients_updated_at
AFTER UPDATE ON recipe_ingredients FOR EACH ROW
BEGIN UPDATE recipe_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
`,
},
];
/**
+2
View File
@@ -20,6 +20,7 @@ import dashboardRouter from './routes/dashboard.js';
import tasksRouter from './routes/tasks.js';
import shoppingRouter from './routes/shopping.js';
import mealsRouter from './routes/meals.js';
import recipesRouter from './routes/recipes.js';
import calendarRouter from './routes/calendar.js';
import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
@@ -172,6 +173,7 @@ app.use('/api/v1/dashboard', dashboardRouter);
app.use('/api/v1/tasks', tasksRouter);
app.use('/api/v1/shopping', shoppingRouter);
app.use('/api/v1/meals', mealsRouter);
app.use('/api/v1/recipes', recipesRouter);
app.use('/api/v1/calendar', calendarRouter);
app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts', contactsRouter);
+20 -6
View File
@@ -7,7 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import { str, oneOf, date, 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';
const log = createLogger('Meals');
@@ -156,15 +156,21 @@ router.post('/', (req, res) => {
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl]);
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId]);
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
if (vRecipeId.value !== null) {
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
}
const meal = db.transaction(() => {
const result = db.get().prepare(`
INSERT INTO meals (date, meal_type, title, notes, recipe_url, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, req.session.userId);
const mealId = result.lastInsertRowid;
@@ -217,16 +223,23 @@ router.put('/:id', (req, res) => {
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }));
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { required: false }));
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(req.body.recipe_id);
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
}
db.get().prepare(`
UPDATE meals
SET date = COALESCE(?, date),
meal_type = COALESCE(?, meal_type),
title = COALESCE(?, title),
notes = ?,
recipe_url = ?
recipe_url = ?,
recipe_id = ?
WHERE id = ?
`).run(
req.body.date ?? null,
@@ -234,6 +247,7 @@ router.put('/:id', (req, res) => {
req.body.title?.trim() ?? null,
req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
id
);
+170
View File
@@ -0,0 +1,170 @@
/**
* Modul: Rezepte (Recipes)
* Zweck: REST-API-Routen fuer Rezept-CRUD inkl. Zutaten
* Abhaengigkeiten: express, server/db.js
*/
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Recipes');
const router = express.Router();
function loadRecipeWithIngredients(id) {
const recipe = db.get().prepare(`
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM recipes r
LEFT JOIN users u ON u.id = r.created_by
WHERE r.id = ?
`).get(id);
if (!recipe) return null;
const ingredients = db.get().prepare(`
SELECT * FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY id ASC
`).all(id);
return { ...recipe, ingredients };
}
router.get('/', (_req, res) => {
try {
const recipes = db.get().prepare(`
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM recipes r
LEFT JOIN users u ON u.id = r.created_by
ORDER BY r.title COLLATE NOCASE ASC, r.id DESC
`).all();
const ids = recipes.map((r) => r.id);
let ingredientMap = {};
if (ids.length > 0) {
const placeholders = ids.map(() => '?').join(',');
const ingredients = db.get().prepare(`
SELECT * FROM recipe_ingredients
WHERE recipe_id IN (${placeholders})
ORDER BY id ASC
`).all(...ids);
for (const ing of ingredients) {
if (!ingredientMap[ing.recipe_id]) ingredientMap[ing.recipe_id] = [];
ingredientMap[ing.recipe_id].push(ing);
}
}
res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) });
} catch (err) {
log.error('GET / Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
router.post('/', (req, res) => {
try {
const { ingredients = [] } = req.body;
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const recipeId = db.transaction(() => {
const result = db.get().prepare(`
INSERT INTO recipes (title, notes, recipe_url, created_by)
VALUES (?, ?, ?, ?)
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
const rid = Number(result.lastInsertRowid);
const insertIng = db.get().prepare(`
INSERT INTO recipe_ingredients (recipe_id, name, quantity, category)
VALUES (?, ?, ?, ?)
`);
for (const ing of ingredients) {
const name = String(ing.name || '').trim().slice(0, MAX_TITLE);
const quantity = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null;
const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges';
if (name) insertIng.run(rid, name, quantity, category);
}
return rid;
});
const created = loadRecipeWithIngredients(recipeId);
res.status(201).json({ data: created });
} catch (err) {
log.error('POST / Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
const existing = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
const { ingredients = [] } = req.body;
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
db.transaction(() => {
db.get().prepare(`
UPDATE recipes
SET title = ?, notes = ?, recipe_url = ?
WHERE id = ?
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, id);
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);
const insertIng = db.get().prepare(`
INSERT INTO recipe_ingredients (recipe_id, name, quantity, category)
VALUES (?, ?, ?, ?)
`);
for (const ing of ingredients) {
const name = String(ing.name || '').trim().slice(0, MAX_TITLE);
const quantity = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null;
const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges';
if (name) insertIng.run(id, name, quantity, category);
}
});
const updated = loadRecipeWithIngredients(id);
res.json({ data: updated });
} catch (err) {
log.error('PUT /:id Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const existing = num(id, 'Rezept-ID', { required: true });
if (existing.error) return res.status(400).json({ error: existing.error, code: 400 });
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
log.error('DELETE /:id Fehler:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
export default router;