feat: pairs table with questions/statements placeholders
- pairs: status, answer_type enum (yes_no/text/word), difficulty_level, FK to questions + 2x statements (positive/negative), auto-timestamps - questions + statements placeholder tables for future use - Safe ALTER TABLE migration for existing pairs placeholder - /api/pairs CRUD route, answer_type required on create Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
README.md
46
README.md
@@ -152,12 +152,34 @@ words ──────────── word_pictures ───────
|
|||||||
|
|
||||||
**Relations:** M2M mit `words`, `pictures`, `pairs` (pairs folgt später)
|
**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 |
|
| Column | Type |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | `UUID` PK |
|
| `id` | `UUID` PK |
|
||||||
| `created_at` | `TIMESTAMPTZ` |
|
| `created_at` / `updated_at` | `TIMESTAMPTZ` |
|
||||||
| `updated_at` | `TIMESTAMPTZ` |
|
|
||||||
|
### Table: `statements` (Platzhalter — Felder folgen)
|
||||||
|
| Column | Type |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `UUID` PK |
|
||||||
|
| `created_at` / `updated_at` | `TIMESTAMPTZ` |
|
||||||
|
|
||||||
### Table: `object_words` (Junction)
|
### Table: `object_words` (Junction)
|
||||||
| Column | Type |
|
| Column | Type |
|
||||||
@@ -387,6 +409,24 @@ curl -X PATCH "$BASE/api/objects/<ID>" \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 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
|
### Utilities
|
||||||
|
|
||||||
#### `GET /api/tables`
|
#### `GET /api/tables`
|
||||||
|
|||||||
@@ -116,15 +116,84 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// pairs placeholder (Felder folgen später)
|
// questions placeholder (Felder folgen später)
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS pairs (
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_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(`
|
await query(`
|
||||||
DROP TRIGGER IF EXISTS pairs_updated_at ON pairs;
|
DROP TRIGGER IF EXISTS pairs_updated_at ON pairs;
|
||||||
CREATE TRIGGER pairs_updated_at
|
CREATE TRIGGER pairs_updated_at
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ app.use('/api/pictures', auth, require('./routes/pictures'));
|
|||||||
app.use('/api/words', auth, require('./routes/words'));
|
app.use('/api/words', auth, require('./routes/words'));
|
||||||
app.use('/api/categories', auth, require('./routes/categories'));
|
app.use('/api/categories', auth, require('./routes/categories'));
|
||||||
app.use('/api/objects', auth, require('./routes/objects'));
|
app.use('/api/objects', auth, require('./routes/objects'));
|
||||||
|
app.use('/api/pairs', auth, require('./routes/pairs'));
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
|
|||||||
98
src/routes/pairs.js
Normal file
98
src/routes/pairs.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user