diff --git a/README.md b/README.md index 4d00b35..d43f845 100644 --- a/README.md +++ b/README.md @@ -152,12 +152,34 @@ words ──────────── word_pictures ─────── **Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später) -### Table: `pairs` (Platzhalter — Felder folgen) +### Table: `pairs` + +| Column | Type | Default | Description | +|---|---|---|---| +| `id` | `UUID` | `gen_random_uuid()` | Primary key | +| `status` | `VARCHAR(20)` | `draft` | `draft` · `blocked` · `published` | +| `answer_type` | `VARCHAR(20)` | — | `yes_no` · `text` · `word` (required) | +| `difficulty_level` | `SMALLINT` | `null` | 1–50 | +| `blocked_topic` | `TEXT` | `null` | Grund/Thema der Blockierung | +| `question_id` | `UUID` FK | `null` | → `questions.id` (SET NULL on delete) | +| `positive_statement_id` | `UUID` FK | `null` | → `statements.id` (SET NULL on delete) | +| `negative_statement_id` | `UUID` FK | `null` | → `statements.id` (SET NULL on delete) | +| `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: `questions` (Platzhalter — Felder folgen) | Column | Type | |---|---| | `id` | `UUID` PK | -| `created_at` | `TIMESTAMPTZ` | -| `updated_at` | `TIMESTAMPTZ` | +| `created_at` / `updated_at` | `TIMESTAMPTZ` | + +### Table: `statements` (Platzhalter — Felder folgen) +| Column | Type | +|---|---| +| `id` | `UUID` PK | +| `created_at` / `updated_at` | `TIMESTAMPTZ` | ### Table: `object_words` (Junction) | Column | Type | @@ -387,6 +409,24 @@ curl -X PATCH "$BASE/api/objects/" \ --- +### Pairs + +#### `GET /api/pairs` — Liste (`?status=draft&answer_type=yes_no&limit=50`) +#### `GET /api/pairs/:id` — Einzelnes Pair +#### `POST /api/pairs` — Neues Pair anlegen (`answer_type` ist Pflichtfeld) + +```bash +curl -X POST "$BASE/api/pairs" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"answer_type": "yes_no", "difficulty_level": 15}' +``` + +#### `PATCH /api/pairs/:id` — Felder aktualisieren +#### `DELETE /api/pairs/:id` — Pair löschen + +--- + ### Utilities #### `GET /api/tables` diff --git a/src/db-migrate.js b/src/db-migrate.js index e4bc919..c20f920 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -116,15 +116,84 @@ async function migrate() { ) `); - // pairs placeholder (Felder folgen später) + // questions placeholder (Felder folgen später) await query(` - CREATE TABLE IF NOT EXISTS pairs ( + CREATE TABLE IF NOT EXISTS questions ( 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 questions_updated_at ON questions; + CREATE TRIGGER questions_updated_at + BEFORE UPDATE ON questions + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // statements placeholder (Felder folgen später) + await query(` + CREATE TABLE IF NOT EXISTS statements ( + 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 statements_updated_at ON statements; + CREATE TRIGGER statements_updated_at + BEFORE UPDATE ON statements + FOR EACH ROW EXECUTE FUNCTION update_updated_at() + `); + + // pairs + await query(` + CREATE TABLE IF NOT EXISTS pairs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'blocked', 'published')), + difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50), + answer_type VARCHAR(20) NOT NULL + CHECK (answer_type IN ('yes_no', 'text', 'word')), + blocked_topic TEXT, + question_id UUID REFERENCES questions(id) ON DELETE SET NULL, + positive_statement_id UUID REFERENCES statements(id) ON DELETE SET NULL, + negative_statement_id UUID REFERENCES statements(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ, + blocked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + // Spalten nachrüsten falls Platzhalter-Tabelle bereits existiert + const pairCols = [ + "status VARCHAR(20) NOT NULL DEFAULT 'draft'", + 'difficulty_level SMALLINT', + "answer_type VARCHAR(20)", + 'blocked_topic TEXT', + 'question_id UUID', + 'positive_statement_id UUID', + 'negative_statement_id UUID', + 'published_at TIMESTAMPTZ', + 'blocked_at TIMESTAMPTZ', + ]; + for (const col of pairCols) { + const name = col.split(' ')[0]; + await query(`ALTER TABLE pairs ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); + } + await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_status_check`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {}); + await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_answer_type_check CHECK (answer_type IN ('yes_no', 'text', 'word'))`).catch(() => {}); + await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_difficulty_level_check`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_difficulty_level_check CHECK (difficulty_level BETWEEN 1 AND 50)`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_question_id_fkey FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE SET NULL`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_positive_statement_id_fkey FOREIGN KEY (positive_statement_id) REFERENCES statements(id) ON DELETE SET NULL`).catch(() => {}); + await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_negative_statement_id_fkey FOREIGN KEY (negative_statement_id) REFERENCES statements(id) ON DELETE SET NULL`).catch(() => {}); + await query(` DROP TRIGGER IF EXISTS pairs_updated_at ON pairs; CREATE TRIGGER pairs_updated_at diff --git a/src/index.js b/src/index.js index a836c4e..6576034 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ 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')); +app.use('/api/pairs', auth, require('./routes/pairs')); // 404 app.use((req, res) => { diff --git a/src/routes/pairs.js b/src/routes/pairs.js new file mode 100644 index 0000000..dde475a --- /dev/null +++ b/src/routes/pairs.js @@ -0,0 +1,98 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const STATUSES = ['draft', 'blocked', 'published']; +const ANSWER_TYPES = ['yes_no', 'text', 'word']; + +const STATUS_TIMESTAMP = { + published: 'published_at', + blocked: 'blocked_at', +}; + +// GET /api/pairs +router.get('/', async (req, res, next) => { + try { + const { status, answer_type, limit = 50, offset = 0 } = req.query; + const conditions = []; + const params = [Math.min(parseInt(limit), 500), parseInt(offset)]; + if (status) { params.push(status); conditions.push(`status = $${params.length}`); } + if (answer_type) { params.push(answer_type); conditions.push(`answer_type = $${params.length}`); } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await query( + `SELECT * FROM pairs ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/pairs/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query('SELECT * FROM pairs WHERE id = $1', [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/pairs +router.post('/', async (req, res, next) => { + try { + const { answer_type, difficulty_level, question_id, + positive_statement_id, negative_statement_id, blocked_topic } = req.body; + + if (!answer_type || !ANSWER_TYPES.includes(answer_type)) + return res.status(400).json({ error: `answer_type required, must be one of: ${ANSWER_TYPES.join(', ')}` }); + + const result = await query( + `INSERT INTO pairs + (answer_type, difficulty_level, question_id, positive_statement_id, negative_statement_id, blocked_topic) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [answer_type, difficulty_level || null, question_id || null, + positive_statement_id || null, negative_statement_id || null, blocked_topic || null] + ); + res.status(201).json(result.rows[0]); + } catch (err) { next(err); } +}); + +// PATCH /api/pairs/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['status', 'answer_type', 'difficulty_level', 'blocked_topic', + 'question_id', 'positive_statement_id', 'negative_statement_id', + '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(', ')}` }); + if (req.body.answer_type && !ANSWER_TYPES.includes(req.body.answer_type)) + return res.status(400).json({ error: `answer_type must be one of: ${ANSWER_TYPES.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 pairs 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/pairs/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM pairs 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;