diff --git a/README.md b/README.md index 0f9c263..4d00b35 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,47 @@ words ──────────── word_pictures ─────── | `created_at` | `TIMESTAMPTZ` | `NOW()` | Auto-set on insert | | `updated_at` | `TIMESTAMPTZ` | `NOW()` | Auto-updated on every change (trigger) | +### Table: `objects` + +| Column | Type | Default | Description | +|---|---|---|---| +| `id` | `UUID` | `gen_random_uuid()` | Primary key | +| `status` | `VARCHAR(20)` | `draft` | `draft` · `blocked` · `published` | +| `selections` | `JSONB` | `null` | Frei strukturierter JSON-Block | +| `notes` | `TEXT` | `null` | Notizen | +| `blocked_topic` | `TEXT` | `null` | Grund/Thema der Blockierung | +| `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) | + +**Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später) + +### Table: `pairs` (Platzhalter — Felder folgen) +| Column | Type | +|---|---| +| `id` | `UUID` PK | +| `created_at` | `TIMESTAMPTZ` | +| `updated_at` | `TIMESTAMPTZ` | + +### Table: `object_words` (Junction) +| Column | Type | +|---|---| +| `object_id` | `UUID` FK → `objects.id` CASCADE | +| `word_id` | `UUID` FK → `words.id` CASCADE | + +### Table: `object_pictures` (Junction) +| Column | Type | +|---|---| +| `object_id` | `UUID` FK → `objects.id` CASCADE | +| `picture_id` | `UUID` FK → `pictures.id` CASCADE | + +### Table: `object_pairs` (Junction) +| Column | Type | +|---|---| +| `object_id` | `UUID` FK → `objects.id` CASCADE | +| `pair_id` | `UUID` FK → `pairs.id` CASCADE | + ### Table: `word_categories` (Junction) | Column | Type | |---|---| @@ -306,6 +347,46 @@ curl -X POST "$BASE/api/categories" \ --- +### Objects + +#### `GET /api/objects` — Liste (`?status=draft&limit=50&offset=0`) +#### `GET /api/objects/:id` — Einzelnes Object inkl. `word_ids`, `picture_ids`, `pair_ids` +#### `POST /api/objects` — Neues Object anlegen + +```bash +curl -X POST "$BASE/api/objects" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"notes": "Erste Notiz", "selections": {"key": "value"}}' +``` + +#### `PATCH /api/objects/:id` — Felder aktualisieren + +```bash +# Publizieren +curl -X PATCH "$BASE/api/objects/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status": "published"}' + +# Blockieren mit Thema +curl -X PATCH "$BASE/api/objects/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status": "blocked", "blocked_topic": "Inhalt ungeeignet"}' +``` + +#### `DELETE /api/objects/:id` — Object löschen (entfernt auch alle Verknüpfungen) + +#### `POST /api/objects/:id/words/:wordId` — Word verknüpfen +#### `DELETE /api/objects/:id/words/:wordId` — Word-Verknüpfung lösen +#### `POST /api/objects/:id/pictures/:pictureId` — Bild verknüpfen +#### `DELETE /api/objects/:id/pictures/:pictureId` — Bild-Verknüpfung lösen +#### `POST /api/objects/:id/pairs/:pairId` — Pair verknüpfen +#### `DELETE /api/objects/:id/pairs/:pairId` — Pair-Verknüpfung lösen + +--- + ### Utilities #### `GET /api/tables` diff --git a/src/db-migrate.js b/src/db-migrate.js index 489c478..e4bc919 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -116,6 +116,72 @@ async function migrate() { ) `); + // pairs placeholder (Felder folgen später) + await query(` + CREATE TABLE IF NOT EXISTS pairs ( + 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 pairs_updated_at ON pairs; + CREATE TRIGGER pairs_updated_at + BEFORE UPDATE ON pairs + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // objects + await query(` + CREATE TABLE IF NOT EXISTS objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'blocked', 'published')), + selections JSONB, + notes TEXT, + blocked_topic TEXT, + 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 objects_updated_at ON objects; + CREATE TRIGGER objects_updated_at + BEFORE UPDATE ON objects + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // M2M: objects <-> words + await query(` + CREATE TABLE IF NOT EXISTS object_words ( + object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE, + PRIMARY KEY (object_id, word_id) + ) + `); + + // M2M: objects <-> pictures + await query(` + CREATE TABLE IF NOT EXISTS object_pictures ( + object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + picture_id UUID NOT NULL REFERENCES pictures(id) ON DELETE CASCADE, + PRIMARY KEY (object_id, picture_id) + ) + `); + + // M2M: objects <-> pairs (Platzhalter) + await query(` + CREATE TABLE IF NOT EXISTS object_pairs ( + object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, + PRIMARY KEY (object_id, pair_id) + ) + `); + console.log('Migration complete'); } diff --git a/src/index.js b/src/index.js index 07b82b0..a836c4e 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,7 @@ 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')); +app.use('/api/objects', auth, require('./routes/objects')); // 404 app.use((req, res) => { diff --git a/src/routes/objects.js b/src/routes/objects.js new file mode 100644 index 0000000..a85b2a6 --- /dev/null +++ b/src/routes/objects.js @@ -0,0 +1,169 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const STATUSES = ['draft', 'blocked', 'published']; + +const STATUS_TIMESTAMP = { + published: 'published_at', + blocked: 'blocked_at', +}; + +async function getWithRelations(id) { + const result = await query( + `SELECT o.*, + COALESCE(json_agg(DISTINCT ow.word_id) FILTER (WHERE ow.word_id IS NOT NULL), '[]') AS word_ids, + COALESCE(json_agg(DISTINCT op.picture_id) FILTER (WHERE op.picture_id IS NOT NULL), '[]') AS picture_ids, + COALESCE(json_agg(DISTINCT opr.pair_id) FILTER (WHERE opr.pair_id IS NOT NULL), '[]') AS pair_ids + FROM objects o + LEFT JOIN object_words ow ON ow.object_id = o.id + LEFT JOIN object_pictures op ON op.object_id = o.id + LEFT JOIN object_pairs opr ON opr.object_id = o.id + WHERE o.id = $1 + GROUP BY o.id`, + [id] + ); + return result.rows[0] || null; +} + +// GET /api/objects +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 o.status = $3` : ''; + if (status) params.push(status); + const result = await query( + `SELECT o.*, + COALESCE(json_agg(DISTINCT ow.word_id) FILTER (WHERE ow.word_id IS NOT NULL), '[]') AS word_ids, + COALESCE(json_agg(DISTINCT op.picture_id) FILTER (WHERE op.picture_id IS NOT NULL), '[]') AS picture_ids, + COALESCE(json_agg(DISTINCT opr.pair_id) FILTER (WHERE opr.pair_id IS NOT NULL), '[]') AS pair_ids + FROM objects o + LEFT JOIN object_words ow ON ow.object_id = o.id + LEFT JOIN object_pictures op ON op.object_id = o.id + LEFT JOIN object_pairs opr ON opr.object_id = o.id + ${where} + GROUP BY o.id + ORDER BY o.created_at DESC + LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/objects/:id +router.get('/:id', async (req, res, next) => { + try { + const row = await getWithRelations(req.params.id); + if (!row) return res.status(404).json({ error: 'Not found' }); + res.json(row); + } catch (err) { next(err); } +}); + +// POST /api/objects +router.post('/', async (req, res, next) => { + try { + const { selections, notes } = req.body; + const result = await query( + `INSERT INTO objects (selections, notes) VALUES ($1, $2) RETURNING *`, + [selections ? JSON.stringify(selections) : null, notes || null] + ); + res.status(201).json({ ...result.rows[0], word_ids: [], picture_ids: [], pair_ids: [] }); + } catch (err) { next(err); } +}); + +// PATCH /api/objects/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['status', 'selections', 'notes', 'blocked_topic', '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 => f === 'selections' ? JSON.stringify(req.body[f]) : req.body[f]); + values.push(req.params.id); + + const result = await query( + `UPDATE objects 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/objects/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM objects 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); } +}); + +// --- Relations --- + +// POST /api/objects/:id/words/:wordId +router.post('/:id/words/:wordId', async (req, res, next) => { + try { + await query(`INSERT INTO object_words (object_id, word_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [req.params.id, req.params.wordId]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// DELETE /api/objects/:id/words/:wordId +router.delete('/:id/words/:wordId', async (req, res, next) => { + try { + await query(`DELETE FROM object_words WHERE object_id = $1 AND word_id = $2`, + [req.params.id, req.params.wordId]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// POST /api/objects/:id/pictures/:pictureId +router.post('/:id/pictures/:pictureId', async (req, res, next) => { + try { + await query(`INSERT INTO object_pictures (object_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/objects/:id/pictures/:pictureId +router.delete('/:id/pictures/:pictureId', async (req, res, next) => { + try { + await query(`DELETE FROM object_pictures WHERE object_id = $1 AND picture_id = $2`, + [req.params.id, req.params.pictureId]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// POST /api/objects/:id/pairs/:pairId +router.post('/:id/pairs/:pairId', async (req, res, next) => { + try { + await query(`INSERT INTO object_pairs (object_id, pair_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [req.params.id, req.params.pairId]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// DELETE /api/objects/:id/pairs/:pairId +router.delete('/:id/pairs/:pairId', async (req, res, next) => { + try { + await query(`DELETE FROM object_pairs WHERE object_id = $1 AND pair_id = $2`, + [req.params.id, req.params.pairId]); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router;