diff --git a/src/db-migrate.js b/src/db-migrate.js index a7b55b3..a122e77 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -149,15 +149,36 @@ async function migrate() { 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() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'blocked', 'published')), + negative_sentence_de TEXT, + negative_sentence_en TEXT, + negative_sentence_se TEXT, + positive_sentence_de TEXT, + positive_sentence_en TEXT, + positive_sentence_se TEXT, + blocked_topic TEXT, + published_at TIMESTAMPTZ, + blocked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); + const stmtCols = [ + "status VARCHAR(20) NOT NULL DEFAULT 'draft'", + 'negative_sentence_de TEXT', 'negative_sentence_en TEXT', 'negative_sentence_se TEXT', + 'positive_sentence_de TEXT', 'positive_sentence_en TEXT', 'positive_sentence_se TEXT', + 'blocked_topic TEXT', 'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ', + ]; + for (const col of stmtCols) + await query(`ALTER TABLE statements ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); + await query(`ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_status_check`).catch(() => {}); + await query(`ALTER TABLE statements ADD CONSTRAINT statements_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {}); + await query(` DROP TRIGGER IF EXISTS statements_updated_at ON statements; CREATE TRIGGER statements_updated_at @@ -165,6 +186,24 @@ async function migrate() { FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); + // M2M: statements <-> words (positive) + await query(` + CREATE TABLE IF NOT EXISTS statement_positive_words ( + statement_id UUID NOT NULL REFERENCES statements(id) ON DELETE CASCADE, + word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE, + PRIMARY KEY (statement_id, word_id) + ) + `); + + // M2M: statements <-> words (negative) + await query(` + CREATE TABLE IF NOT EXISTS statement_negative_words ( + statement_id UUID NOT NULL REFERENCES statements(id) ON DELETE CASCADE, + word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE, + PRIMARY KEY (statement_id, word_id) + ) + `); + // pairs await query(` CREATE TABLE IF NOT EXISTS pairs ( diff --git a/src/index.js b/src/index.js index a78b5a4..2960dcd 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,7 @@ app.use('/api/categories', auth, require('./routes/categories')); app.use('/api/objects', auth, require('./routes/objects')); app.use('/api/pairs', auth, require('./routes/pairs')); app.use('/api/questions', auth, require('./routes/questions')); +app.use('/api/statements', auth, require('./routes/statements')); // 404 app.use((req, res) => { diff --git a/src/routes/statements.js b/src/routes/statements.js new file mode 100644 index 0000000..3fc7b81 --- /dev/null +++ b/src/routes/statements.js @@ -0,0 +1,158 @@ +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 s.*, + COALESCE(json_agg(DISTINCT spw.word_id) FILTER (WHERE spw.word_id IS NOT NULL), '[]') AS positive_word_ids, + COALESCE(json_agg(DISTINCT snw.word_id) FILTER (WHERE snw.word_id IS NOT NULL), '[]') AS negative_word_ids + FROM statements s + LEFT JOIN statement_positive_words spw ON spw.statement_id = s.id + LEFT JOIN statement_negative_words snw ON snw.statement_id = s.id + WHERE s.id = $1 + GROUP BY s.id`, + [id] + ); + return result.rows[0] || null; +} + +// GET /api/statements +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 s.status = $3` : ''; + if (status) params.push(status); + const result = await query( + `SELECT s.*, + COALESCE(json_agg(DISTINCT spw.word_id) FILTER (WHERE spw.word_id IS NOT NULL), '[]') AS positive_word_ids, + COALESCE(json_agg(DISTINCT snw.word_id) FILTER (WHERE snw.word_id IS NOT NULL), '[]') AS negative_word_ids + FROM statements s + LEFT JOIN statement_positive_words spw ON spw.statement_id = s.id + LEFT JOIN statement_negative_words snw ON snw.statement_id = s.id + ${where} + GROUP BY s.id + ORDER BY s.created_at DESC + LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/statements/: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/statements +router.post('/', async (req, res, next) => { + try { + const { + negative_sentence_de, negative_sentence_en, negative_sentence_se, + positive_sentence_de, positive_sentence_en, positive_sentence_se, + blocked_topic, + } = req.body; + const result = await query( + `INSERT INTO statements + (negative_sentence_de, negative_sentence_en, negative_sentence_se, + positive_sentence_de, positive_sentence_en, positive_sentence_se, blocked_topic) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [negative_sentence_de || null, negative_sentence_en || null, negative_sentence_se || null, + positive_sentence_de || null, positive_sentence_en || null, positive_sentence_se || null, + blocked_topic || null] + ); + res.status(201).json({ ...result.rows[0], positive_word_ids: [], negative_word_ids: [] }); + } catch (err) { next(err); } +}); + +// PATCH /api/statements/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['status', 'blocked_topic', 'published_at', 'blocked_at', + 'negative_sentence_de', 'negative_sentence_en', 'negative_sentence_se', + 'positive_sentence_de', 'positive_sentence_en', 'positive_sentence_se']; + 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 statements 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/statements/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM statements 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/statements/:id/positive-words/:wordId +router.post('/:id/positive-words/:wordId', async (req, res, next) => { + try { + await query( + `INSERT INTO statement_positive_words (statement_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/statements/:id/positive-words/:wordId +router.delete('/:id/positive-words/:wordId', async (req, res, next) => { + try { + await query( + `DELETE FROM statement_positive_words WHERE statement_id = $1 AND word_id = $2`, + [req.params.id, req.params.wordId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// POST /api/statements/:id/negative-words/:wordId +router.post('/:id/negative-words/:wordId', async (req, res, next) => { + try { + await query( + `INSERT INTO statement_negative_words (statement_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/statements/:id/negative-words/:wordId +router.delete('/:id/negative-words/:wordId', async (req, res, next) => { + try { + await query( + `DELETE FROM statement_negative_words WHERE statement_id = $1 AND word_id = $2`, + [req.params.id, req.params.wordId] + ); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router;