diff --git a/README.md b/README.md index bf7b341..59993a6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ published → blocked **Planned relations:** - `design` → will become a FK to a `designs` table -- `words` → M2M via junction table once `words` table exists +- `words` → M2M via `word_pictures` junction table --- @@ -88,6 +88,53 @@ Deleting a picture via the API also deletes the file from the bucket. --- +## Database Relations + +``` +words ──────────── word_pictures ──────────── pictures + │ + └──────────── word_categories ──────────── categories +``` + +### Table: `words` + +| 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` · `translated` · `generated` · `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) | + +**Status flow:** `requested → translated → generated → published` (blocked possible at any point) + +### Table: `word_pictures` (Junction) +| Column | Type | +|---|---| +| `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: `word_categories` (Junction) +| Column | Type | +|---|---| +| `word_id` | `UUID` FK → `words.id` CASCADE | +| `category_id` | `UUID` FK → `categories.id` CASCADE | + +--- + ## API Endpoints ### Pictures @@ -201,6 +248,37 @@ curl -X DELETE "$BASE/api/pictures/" \ --- +### Words + +#### `GET /api/words` — Liste (`?status=requested&limit=50&offset=0`) +#### `GET /api/words/:id` — Einzelnes Word inkl. `picture_ids` und `category_ids` +#### `POST /api/words` — Neues Word anlegen + +```bash +curl -X POST "$BASE/api/words" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"titel_de": "Hund", "titel_en": "Dog", "titel_se": "Hund", "difficulty_level": 5}' +``` + +#### `PATCH /api/words/:id` — Felder aktualisieren (Status setzt Timestamp automatisch) + +```bash +curl -X PATCH "$BASE/api/words/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status": "published"}' +``` + +#### `DELETE /api/words/:id` — Word löschen (entfernt auch alle Verknüpfungen) + +#### `POST /api/words/:id/pictures/:pictureId` — Bild verknüpfen +#### `DELETE /api/words/:id/pictures/:pictureId` — Bild-Verknüpfung lösen +#### `POST /api/words/:id/categories/:categoryId` — Kategorie verknüpfen +#### `DELETE /api/words/:id/categories/:categoryId` — Kategorie-Verknüpfung lösen + +--- + ### Utilities #### `GET /api/tables` diff --git a/src/db-migrate.js b/src/db-migrate.js index 84b669b..2519725 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -34,7 +34,66 @@ async function migrate() { FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); - console.log('Migration complete: pictures table ready'); + // words + await query(` + CREATE TABLE IF NOT EXISTS words ( + 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', 'translated', 'generated', '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() + ) + `); + + await query(` + DROP TRIGGER IF EXISTS words_updated_at ON words; + CREATE TRIGGER words_updated_at + BEFORE UPDATE ON words + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // M2M: words <-> pictures + await query(` + CREATE TABLE IF NOT EXISTS word_pictures ( + word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE, + picture_id UUID NOT NULL REFERENCES pictures(id) ON DELETE CASCADE, + PRIMARY KEY (word_id, picture_id) + ) + `); + + // 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() + ) + `); + + await query(` + DROP TRIGGER IF EXISTS categories_updated_at ON categories; + CREATE TRIGGER categories_updated_at + BEFORE UPDATE ON categories + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // M2M: words <-> categories + await query(` + CREATE TABLE IF NOT EXISTS word_categories ( + word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + PRIMARY KEY (word_id, category_id) + ) + `); + + console.log('Migration complete'); } module.exports = migrate; diff --git a/src/index.js b/src/index.js index 62714f9..d980357 100644 --- a/src/index.js +++ b/src/index.js @@ -25,6 +25,7 @@ app.get('/health', async (req, res) => { // Routes — protected by Bearer token app.use('/api', auth, require('./routes/index')); app.use('/api/pictures', auth, require('./routes/pictures')); +app.use('/api/words', auth, require('./routes/words')); // 404 app.use((req, res) => { diff --git a/src/routes/words.js b/src/routes/words.js new file mode 100644 index 0000000..ebfbcbd --- /dev/null +++ b/src/routes/words.js @@ -0,0 +1,154 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const STATUSES = ['requested', 'translated', 'generated', 'blocked', 'published']; + +const STATUS_TIMESTAMP = { + requested: 'requested_at', + published: 'published_at', + blocked: 'blocked_at', +}; + +// GET /api/words +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 w.status = $3` : ''; + if (status) params.push(status); + const result = await query( + `SELECT w.*, + COALESCE(json_agg(DISTINCT p.id) FILTER (WHERE p.id IS NOT NULL), '[]') AS picture_ids, + COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids + FROM words w + LEFT JOIN word_pictures wp ON wp.word_id = w.id + LEFT JOIN pictures p ON p.id = wp.picture_id + LEFT JOIN word_categories wc ON wc.word_id = w.id + LEFT JOIN categories c ON c.id = wc.category_id + ${where} + GROUP BY w.id + ORDER BY w.created_at DESC + LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/words/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query( + `SELECT w.*, + COALESCE(json_agg(DISTINCT p.id) FILTER (WHERE p.id IS NOT NULL), '[]') AS picture_ids, + COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids + FROM words w + LEFT JOIN word_pictures wp ON wp.word_id = w.id + LEFT JOIN pictures p ON p.id = wp.picture_id + LEFT JOIN word_categories wc ON wc.word_id = w.id + LEFT JOIN categories c ON c.id = wc.category_id + WHERE w.id = $1 + GROUP BY w.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/words +router.post('/', async (req, res, next) => { + try { + const { titel_de, titel_en, titel_se, difficulty_level } = req.body; + const result = await query( + `INSERT INTO words (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], picture_ids: [], category_ids: [] }); + } catch (err) { next(err); } +}); + +// PATCH /api/words/: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(', ')}` }); + + // Auto-timestamp bei Statuswechsel + 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 words 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/words/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM words 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); } +}); + +// POST /api/words/:id/pictures/:pictureId — Bild verknüpfen +router.post('/:id/pictures/:pictureId', async (req, res, next) => { + try { + await query( + `INSERT INTO word_pictures (word_id, picture_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [req.params.id, req.params.pictureId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// DELETE /api/words/:id/pictures/:pictureId — Bild-Verknüpfung entfernen +router.delete('/:id/pictures/:pictureId', async (req, res, next) => { + try { + await query( + `DELETE FROM word_pictures WHERE word_id = $1 AND picture_id = $2`, + [req.params.id, req.params.pictureId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// POST /api/words/:id/categories/:categoryId — Kategorie verknüpfen +router.post('/:id/categories/:categoryId', async (req, res, next) => { + try { + await query( + `INSERT INTO word_categories (word_id, category_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [req.params.id, req.params.categoryId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// DELETE /api/words/:id/categories/:categoryId — Kategorie-Verknüpfung entfernen +router.delete('/:id/categories/:categoryId', async (req, res, next) => { + try { + await query( + `DELETE FROM word_categories WHERE word_id = $1 AND category_id = $2`, + [req.params.id, req.params.categoryId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router;