201 lines
7.9 KiB
JavaScript
201 lines
7.9 KiB
JavaScript
/**
|
|
* 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();
|
|
|
|
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
|
|
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
|
|
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
|
|
|
|
function normalizeEnum(value, allowed, fallback = null) {
|
|
const normalized = String(value || '').trim().toLowerCase();
|
|
return allowed.includes(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function normalizeTags(value) {
|
|
const tags = Array.isArray(value)
|
|
? value
|
|
: String(value || '').split(',');
|
|
return tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean).slice(0, 12);
|
|
}
|
|
|
|
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 / error:', err);
|
|
res.status(500).json({ error: 'Internal error', 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 mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, 'other');
|
|
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, 'mixed');
|
|
const style = normalizeEnum(req.body.style, VALID_STYLES, 'family');
|
|
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
|
|
|
const recipeId = db.transaction(() => {
|
|
const result = db.get().prepare(`
|
|
INSERT INTO recipes (title, notes, recipe_url, meal_category, protein, style, tags_json, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, 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 / error:', err);
|
|
res.status(500).json({ error: 'Internal error', 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, created_by FROM recipes WHERE id = ?').get(id);
|
|
if (!existing) return res.status(404).json({ error: 'Recipe not found', code: 404 });
|
|
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 });
|
|
|
|
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 mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, existing.meal_category || 'other');
|
|
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, existing.protein || 'mixed');
|
|
const style = normalizeEnum(req.body.style, VALID_STYLES, existing.style || 'family');
|
|
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
|
|
|
db.transaction(() => {
|
|
db.get().prepare(`
|
|
UPDATE recipes
|
|
SET title = ?, notes = ?, recipe_url = ?, meal_category = ?, protein = ?, style = ?, tags_json = ?
|
|
WHERE id = ?
|
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, 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 error:', err);
|
|
res.status(500).json({ error: 'Internal error', code: 500 });
|
|
}
|
|
});
|
|
|
|
router.delete('/:id', (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Invalid recipe ID.', code: 400 });
|
|
|
|
const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
|
|
if (!existing) return res.status(404).json({ error: 'Recipe not found.', code: 404 });
|
|
if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Not authorized.', code: 403 });
|
|
|
|
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
|
if (result.changes === 0) return res.status(404).json({ error: 'Recipe not found', code: 404 });
|
|
|
|
res.status(204).end();
|
|
} catch (err) {
|
|
log.error('DELETE /:id error:', err);
|
|
res.status(500).json({ error: 'Internal error', code: 500 });
|
|
}
|
|
});
|
|
|
|
export default router;
|