From 8bd4240ea91ec006b3559052e05b2c68418ce06b Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 20 May 2026 14:10:22 +0200 Subject: [PATCH] feat: categories table with full CRUD - Fills out placeholder categories table with all fields - Trilingual titles, status enum, difficulty level, auto-timestamps - ALTER TABLE IF NOT EXISTS for safe migration on existing table - /api/categories CRUD route, word_ids included in responses Co-Authored-By: Claude Sonnet 4.6 --- README.md | 39 ++++++++++++--- src/db-migrate.js | 31 ++++++++++-- src/index.js | 1 + src/routes/categories.js | 103 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/routes/categories.js diff --git a/README.md b/README.md index 59993a6..0f9c263 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,21 @@ words ──────────── word_pictures ─────── | `word_id` | `UUID` FK → `words.id` CASCADE | | `picture_id` | `UUID` FK → `pictures.id` CASCADE | -### Table: `categories` (Platzhalter — Felder folgen) -| Column | Type | -|---|---| -| `id` | `UUID` PK | -| `created_at` | `TIMESTAMPTZ` | -| `updated_at` | `TIMESTAMPTZ` | +### Table: `categories` + +| Column | Type | Default | Description | +|---|---|---|---| +| `id` | `UUID` | `gen_random_uuid()` | Primary key | +| `titel_de` | `TEXT` | `null` | German title | +| `titel_en` | `TEXT` | `null` | English title | +| `titel_se` | `TEXT` | `null` | Swedish title | +| `status` | `VARCHAR(20)` | `requested` | `requested` · `blocked` · `published` | +| `difficulty_level` | `SMALLINT` | `null` | 1–50 | +| `requested_at` | `TIMESTAMPTZ` | `NOW()` on insert | Auto-set on create | +| `published_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `published` | +| `blocked_at` | `TIMESTAMPTZ` | `null` | Auto-set when status → `blocked` | +| `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | +| `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | ### Table: `word_categories` (Junction) | Column | Type | @@ -279,6 +288,24 @@ curl -X PATCH "$BASE/api/words/" \ --- +### Categories + +#### `GET /api/categories` — Liste (`?status=requested&limit=50&offset=0`) +#### `GET /api/categories/:id` — Einzelne Kategorie inkl. `word_ids` +#### `POST /api/categories` — Neue Kategorie anlegen + +```bash +curl -X POST "$BASE/api/categories" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"titel_de": "Tiere", "titel_en": "Animals", "titel_se": "Djur", "difficulty_level": 10}' +``` + +#### `PATCH /api/categories/:id` — Felder aktualisieren (Status setzt Timestamp automatisch) +#### `DELETE /api/categories/:id` — Kategorie löschen (entfernt auch alle Verknüpfungen) + +--- + ### Utilities #### `GET /api/tables` diff --git a/src/db-migrate.js b/src/db-migrate.js index 2519725..489c478 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -68,15 +68,38 @@ async function migrate() { ) `); - // categories (Platzhalter — Felder folgen im nächsten Schritt) await query(` CREATE TABLE IF NOT EXISTS categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + titel_de TEXT, + titel_en TEXT, + titel_se TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'requested' + CHECK (status IN ('requested', 'blocked', 'published')), + difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50), + requested_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + blocked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); + // Felder nachrüsten falls Tabelle schon als Platzhalter existiert + const catCols = ['titel_de TEXT', 'titel_en TEXT', 'titel_se TEXT', + "status VARCHAR(20) NOT NULL DEFAULT 'requested'", + 'difficulty_level SMALLINT', 'requested_at TIMESTAMPTZ', + 'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ']; + for (const col of catCols) { + const name = col.split(' ')[0]; + await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); + // CHECK constraint nur wenn noch nicht vorhanden + if (name === 'status') { + await query(`ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_status_check`).catch(() => {}); + await query(`ALTER TABLE categories ADD CONSTRAINT categories_status_check CHECK (status IN ('requested', 'blocked', 'published'))`).catch(() => {}); + } + } + await query(` DROP TRIGGER IF EXISTS categories_updated_at ON categories; CREATE TRIGGER categories_updated_at diff --git a/src/index.js b/src/index.js index d980357..07b82b0 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,7 @@ app.get('/health', async (req, res) => { app.use('/api', auth, require('./routes/index')); app.use('/api/pictures', auth, require('./routes/pictures')); app.use('/api/words', auth, require('./routes/words')); +app.use('/api/categories', auth, require('./routes/categories')); // 404 app.use((req, res) => { diff --git a/src/routes/categories.js b/src/routes/categories.js new file mode 100644 index 0000000..965f2e9 --- /dev/null +++ b/src/routes/categories.js @@ -0,0 +1,103 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const STATUSES = ['requested', 'blocked', 'published']; + +const STATUS_TIMESTAMP = { + requested: 'requested_at', + published: 'published_at', + blocked: 'blocked_at', +}; + +// GET /api/categories +router.get('/', async (req, res, next) => { + try { + const { status, limit = 50, offset = 0 } = req.query; + const params = [Math.min(parseInt(limit), 500), parseInt(offset)]; + const where = status ? `WHERE c.status = $3` : ''; + if (status) params.push(status); + const result = await query( + `SELECT c.*, + COALESCE(json_agg(DISTINCT w.id) FILTER (WHERE w.id IS NOT NULL), '[]') AS word_ids + FROM categories c + LEFT JOIN word_categories wc ON wc.category_id = c.id + LEFT JOIN words w ON w.id = wc.word_id + ${where} + GROUP BY c.id + ORDER BY c.created_at DESC + LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/categories/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query( + `SELECT c.*, + COALESCE(json_agg(DISTINCT w.id) FILTER (WHERE w.id IS NOT NULL), '[]') AS word_ids + FROM categories c + LEFT JOIN word_categories wc ON wc.category_id = c.id + LEFT JOIN words w ON w.id = wc.word_id + WHERE c.id = $1 + GROUP BY c.id`, + [req.params.id] + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// POST /api/categories +router.post('/', async (req, res, next) => { + try { + const { titel_de, titel_en, titel_se, difficulty_level } = req.body; + const result = await query( + `INSERT INTO categories (titel_de, titel_en, titel_se, difficulty_level, requested_at) + VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, + [titel_de || null, titel_en || null, titel_se || null, difficulty_level || null] + ); + res.status(201).json({ ...result.rows[0], word_ids: [] }); + } catch (err) { next(err); } +}); + +// PATCH /api/categories/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['titel_de', 'titel_en', 'titel_se', 'status', + 'difficulty_level', 'requested_at', 'published_at', 'blocked_at']; + const fields = Object.keys(req.body).filter(k => allowed.includes(k)); + if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); + + if (req.body.status && !STATUSES.includes(req.body.status)) + return res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` }); + + const tsField = STATUS_TIMESTAMP[req.body.status]; + if (tsField && !req.body[tsField]) { + fields.push(tsField); + req.body[tsField] = new Date().toISOString(); + } + + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => req.body[f]), req.params.id]; + const result = await query( + `UPDATE categories SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`, + values + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// DELETE /api/categories/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM categories WHERE id = $1 RETURNING id', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router;