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
This commit is contained in:
@@ -331,6 +331,30 @@ const MIGRATIONS = [
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
description: 'Einkaufskategorien als eigene Tabelle (anpassbar, sortierbar)',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS shopping_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT NOT NULL DEFAULT 'tag',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT INTO shopping_categories (name, icon, sort_order) VALUES
|
||||
('Obst & Gemüse', 'apple', 0),
|
||||
('Backwaren', 'wheat', 1),
|
||||
('Milchprodukte', 'milk', 2),
|
||||
('Fleisch & Fisch', 'beef', 3),
|
||||
('Tiefkühl', 'snowflake', 4),
|
||||
('Getränke', 'cup-soda', 5),
|
||||
('Haushalt', 'spray-can', 6),
|
||||
('Drogerie', 'pill', 7),
|
||||
('Sonstiges', 'shopping-basket', 8);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+179
-15
@@ -1,29 +1,186 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten (Shopping)
|
||||
* Zweck: REST-API-Routen für Einkaufslisten, Artikel, Autocomplete
|
||||
* Zweck: REST-API-Routen für Einkaufslisten, Artikel, Kategorien, Autocomplete
|
||||
* Abhängigkeiten: express, server/db.js
|
||||
*
|
||||
* Routen-Reihenfolge: Statische Pfade (/suggestions, /items/:id) müssen
|
||||
* 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, collectErrors, MAX_TITLE, MAX_SHORT } from '../middleware/validate.js';
|
||||
import { str, oneOf, num, collectErrors, MAX_TITLE, MAX_SHORT } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('Shopping');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
// Hilfsfunktionen
|
||||
// --------------------------------------------------------
|
||||
|
||||
const ITEM_CATEGORIES = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
/** 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=…
|
||||
@@ -70,7 +227,9 @@ router.patch('/items/:itemId', (req, res) => {
|
||||
} = req.body;
|
||||
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'name darf nicht leer sein.', code: 400 });
|
||||
if (category && !ITEM_CATEGORIES.includes(category))
|
||||
|
||||
const validNames = validCategoryNames();
|
||||
if (category && !validNames.includes(category))
|
||||
return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
|
||||
|
||||
db.get().prepare(`
|
||||
@@ -207,7 +366,7 @@ router.delete('/:listId', (req, res) => {
|
||||
// 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 }
|
||||
// Response: { data: ShoppingItem[], list: ShoppingList, categories: ShoppingCategory[] }
|
||||
// --------------------------------------------------------
|
||||
router.get('/:listId/items', (req, res) => {
|
||||
try {
|
||||
@@ -216,18 +375,19 @@ router.get('/:listId/items', (req, res) => {
|
||||
.get(req.params.listId);
|
||||
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
|
||||
|
||||
const categoryOrder = ITEM_CATEGORIES.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' ');
|
||||
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 ${ITEM_CATEGORIES.length} END,
|
||||
CASE category ${categoryOrder} ELSE ${categories.length} END,
|
||||
is_checked ASC,
|
||||
created_at ASC
|
||||
`).all(req.params.listId);
|
||||
|
||||
res.json({ data: items, list });
|
||||
res.json({ data: items, list, categories });
|
||||
} catch (err) {
|
||||
log.error('GET /:listId/items Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
@@ -247,16 +407,20 @@ router.post('/:listId/items', (req, res) => {
|
||||
.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(req.body.category || 'Sonstiges', ITEM_CATEGORIES, 'Kategorie');
|
||||
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 || 'Sonstiges');
|
||||
`).run(req.params.listId, vName.value, vQty.value, vCat.value || defaultCat);
|
||||
|
||||
const item = db.get()
|
||||
.prepare('SELECT * FROM shopping_items WHERE id = ?')
|
||||
|
||||
Reference in New Issue
Block a user