Files
oikos/server/routes/shopping.js
T
Ulas 2dc8984c3e feat(shopping): custom categories - add, rename, delete and reorder (#26)
- New DB table shopping_categories (migration v5) seeds 9 default
  categories with Lucide icons and sort_order
- Backend CRUD routes: GET/POST/PUT/DELETE /shopping/categories
  plus PATCH /shopping/categories/reorder
- Category validation now uses DB instead of hardcoded constant;
  items of deleted category are moved to the next available one
- Frontend shopping page loads categories from API, dropdown and
  grouping reflect custom order dynamically
- Settings -> Shopping section: list categories with up/down buttons,
  click-to-rename, delete with confirmation; add new categories inline
- i18n keys added in de/en/sv/it
2026-04-05 17:24:06 +02:00

453 lines
16 KiB
JavaScript

/**
* Modul: Einkaufslisten (Shopping)
* Zweck: REST-API-Routen für Einkaufslisten, Artikel, Kategorien, Autocomplete
* Abhängigkeiten: express, server/db.js
*
* Routen-Reihenfolge: Statische Pfade (/suggestions, /categories, /items/:id) müssen
* vor dynamischen (/:listId) registriert sein, damit Express korrekt matcht.
*/
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import { str, oneOf, num, collectErrors, MAX_TITLE, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Shopping');
const router = express.Router();
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
/** Alle Kategorien aus DB laden (nach sort_order sortiert). */
function loadCategories() {
return db.get().prepare('SELECT * FROM shopping_categories ORDER BY sort_order ASC').all();
}
/** Kategorie-Namen-Array für Validierung. */
function validCategoryNames() {
return loadCategories().map((c) => c.name);
}
// --------------------------------------------------------
// GET /api/v1/shopping/categories
// Alle Kategorien zurückgeben.
// Response: { data: ShoppingCategory[] }
// --------------------------------------------------------
router.get('/categories', (_req, res) => {
try {
res.json({ data: loadCategories() });
} catch (err) {
log.error('GET /categories Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// POST /api/v1/shopping/categories
// Neue Kategorie erstellen.
// Body: { name }
// Response: { data: ShoppingCategory }
// --------------------------------------------------------
router.post('/categories', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const existing = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE')
.get(vName.value);
if (existing) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
const maxOrder = db.get()
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories')
.get().m;
const result = db.get()
.prepare('INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)')
.run(vName.value, 'tag', maxOrder + 1);
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json({ data: cat });
} catch (err) {
log.error('POST /categories Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PUT /api/v1/shopping/categories/:catId
// Kategorie umbenennen.
// Body: { name }
// Response: { data: ShoppingCategory }
// --------------------------------------------------------
router.put('/categories/:catId', (req, res) => {
try {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const conflict = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?')
.get(vName.value, cat.id);
if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
// Artikel, die die alte Kategorie nutzen, mitumbenennen
db.get().transaction(() => {
db.get()
.prepare('UPDATE shopping_items SET category = ? WHERE category = ?')
.run(vName.value, cat.name);
db.get()
.prepare('UPDATE shopping_categories SET name = ? WHERE id = ?')
.run(vName.value, cat.id);
})();
const updated = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(cat.id);
res.json({ data: updated });
} catch (err) {
log.error('PUT /categories/:catId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/shopping/categories/:catId
// Kategorie löschen (Artikel werden zu "Sonstiges" verschoben).
// Die letzte verbleibende Kategorie kann nicht gelöscht werden.
// Response: { ok: true }
// --------------------------------------------------------
router.delete('/categories/:catId', (req, res) => {
try {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
const total = db.get()
.prepare('SELECT COUNT(*) AS c FROM shopping_categories')
.get().c;
if (total <= 1) return res.status(400).json({ error: 'Letzte Kategorie kann nicht gelöscht werden.', code: 400 });
// Fallback-Kategorie: erste andere Kategorie nach sort_order
const fallback = db.get()
.prepare('SELECT name FROM shopping_categories WHERE id != ? ORDER BY sort_order ASC LIMIT 1')
.get(cat.id);
db.get().transaction(() => {
db.get()
.prepare('UPDATE shopping_items SET category = ? WHERE category = ?')
.run(fallback.name, cat.name);
db.get()
.prepare('DELETE FROM shopping_categories WHERE id = ?')
.run(cat.id);
})();
res.json({ ok: true });
} catch (err) {
log.error('DELETE /categories/:catId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PATCH /api/v1/shopping/categories/reorder
// Reihenfolge der Kategorien ändern.
// Body: { order: number[] } (Array von IDs in gewünschter Reihenfolge)
// Response: { data: ShoppingCategory[] }
// --------------------------------------------------------
router.patch('/categories/reorder', (req, res) => {
try {
const { order } = req.body;
if (!Array.isArray(order) || order.length === 0)
return res.status(400).json({ error: 'order muss ein nicht-leeres Array von IDs sein.', code: 400 });
const update = db.get().prepare('UPDATE shopping_categories SET sort_order = ? WHERE id = ?');
db.get().transaction(() => {
order.forEach((id, idx) => update.run(idx, id));
})();
res.json({ data: loadCategories() });
} catch (err) {
log.error('PATCH /categories/reorder Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/shopping/suggestions?q=…
// Autocomplete-Vorschläge aus bisherigen Artikelnamen.
// Response: { data: string[] }
// --------------------------------------------------------
router.get('/suggestions', (req, res) => {
try {
const q = (req.query.q ?? '').trim();
if (q.length < 1) return res.json({ data: [] });
const rows = db.get().prepare(`
SELECT DISTINCT name FROM shopping_items
WHERE name LIKE ? COLLATE NOCASE
ORDER BY name ASC
LIMIT 8
`).all(`${q}%`);
res.json({ data: rows.map((r) => r.name) });
} catch (err) {
log.error('suggestions Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PATCH /api/v1/shopping/items/:itemId
// Artikel aktualisieren (is_checked, name, quantity, category).
// Body: { is_checked?, name?, quantity?, category? }
// Response: { data: ShoppingItem }
// --------------------------------------------------------
router.patch('/items/:itemId', (req, res) => {
try {
const item = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?')
.get(req.params.itemId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
const {
is_checked = item.is_checked,
name = item.name,
quantity = item.quantity,
category = item.category,
} = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name darf nicht leer sein.', code: 400 });
const validNames = validCategoryNames();
if (category && !validNames.includes(category))
return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
db.get().prepare(`
UPDATE shopping_items
SET is_checked = ?, name = ?, quantity = ?, category = ?
WHERE id = ?
`).run(is_checked ? 1 : 0, name.trim(), quantity ?? null, category, req.params.itemId);
const updated = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?')
.get(req.params.itemId);
res.json({ data: updated });
} catch (err) {
log.error('PATCH items/:id Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/shopping/items/:itemId
// Einzelnen Artikel löschen.
// Response: { ok: true }
// --------------------------------------------------------
router.delete('/items/:itemId', (req, res) => {
try {
const result = db.get()
.prepare('DELETE FROM shopping_items WHERE id = ?')
.run(req.params.itemId);
if (result.changes === 0)
return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
res.json({ ok: true });
} catch (err) {
log.error('DELETE items/:id Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/shopping
// Alle Einkaufslisten mit Artikel-Zähler.
// Response: { data: ShoppingList[] }
// --------------------------------------------------------
router.get('/', (req, res) => {
try {
const lists = db.get().prepare(`
SELECT
sl.*,
COUNT(si.id) AS item_total,
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
FROM shopping_lists sl
LEFT JOIN shopping_items si ON si.list_id = sl.id
GROUP BY sl.id
ORDER BY sl.created_at ASC
`).all();
res.json({ data: lists });
} catch (err) {
log.error('GET / Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// POST /api/v1/shopping
// Neue Einkaufsliste erstellen.
// Body: { name }
// Response: { data: ShoppingList }
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const result = db.get()
.prepare('INSERT INTO shopping_lists (name, created_by) VALUES (?, ?)')
.run(vName.value, req.session.userId);
const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json({ data: list });
} catch (err) {
log.error('POST / Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PUT /api/v1/shopping/:listId
// Einkaufsliste umbenennen.
// Body: { name }
// Response: { data: ShoppingList }
// --------------------------------------------------------
router.put('/:listId', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const result = db.get()
.prepare('UPDATE shopping_lists SET name = ? WHERE id = ?')
.run(vName.value, req.params.listId);
if (result.changes === 0)
return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
res.json({ data: list });
} catch (err) {
log.error('PUT /:listId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/shopping/:listId
// Liste und alle Artikel löschen (CASCADE).
// Response: { ok: true }
// --------------------------------------------------------
router.delete('/:listId', (req, res) => {
try {
const result = db.get()
.prepare('DELETE FROM shopping_lists WHERE id = ?')
.run(req.params.listId);
if (result.changes === 0)
return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
res.json({ ok: true });
} catch (err) {
log.error('DELETE /:listId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/shopping/:listId/items
// Alle Artikel einer Liste, sortiert nach Supermarkt-Gang-Logik.
// Abgehakte Artikel ans Ende innerhalb ihrer Kategorie.
// Response: { data: ShoppingItem[], list: ShoppingList, categories: ShoppingCategory[] }
// --------------------------------------------------------
router.get('/:listId/items', (req, res) => {
try {
const list = db.get()
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
const categories = loadCategories();
const categoryOrder = categories.map((c, i) => `WHEN '${c.name.replace(/'/g, "''")}' THEN ${i}`).join(' ');
const items = db.get().prepare(`
SELECT * FROM shopping_items
WHERE list_id = ?
ORDER BY
CASE category ${categoryOrder} ELSE ${categories.length} END,
is_checked ASC,
created_at ASC
`).all(req.params.listId);
res.json({ data: items, list, categories });
} catch (err) {
log.error('GET /:listId/items Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// POST /api/v1/shopping/:listId/items
// Artikel zur Liste hinzufügen.
// Body: { name, quantity?, category? }
// Response: { data: ShoppingItem }
// --------------------------------------------------------
router.post('/:listId/items', (req, res) => {
try {
const list = db.get()
.prepare('SELECT id FROM shopping_lists WHERE id = ?')
.get(req.params.listId);
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
const validNames = validCategoryNames();
const defaultCat = validNames[0] ?? 'Sonstiges';
const requestedCat = req.body.category || defaultCat;
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vQty = str(req.body.quantity, 'Menge', { max: MAX_SHORT, required: false });
const vCat = oneOf(requestedCat, validNames, 'Kategorie');
const errors = collectErrors([vName, vQty, vCat]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = db.get().prepare(`
INSERT INTO shopping_items (list_id, name, quantity, category)
VALUES (?, ?, ?, ?)
`).run(req.params.listId, vName.value, vQty.value, vCat.value || defaultCat);
const item = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json({ data: item });
} catch (err) {
log.error('POST /:listId/items Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/shopping/:listId/items/checked
// Alle abgehakten Artikel aus einer Liste löschen.
// Response: { deleted: number }
// --------------------------------------------------------
router.delete('/:listId/items/checked', (req, res) => {
try {
const result = db.get().prepare(`
DELETE FROM shopping_items WHERE list_id = ? AND is_checked = 1
`).run(req.params.listId);
res.json({ deleted: result.changes });
} catch (err) {
log.error('DELETE /:listId/items/checked Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
export default router;