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:
committed by
Ulas Kalayci
parent
41467a84b6
commit
0b54fe255b
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user