From a3ff787259faaf797c7aa6684e60f1c99bf78a02 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 3 Jun 2026 07:35:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20reviewed-Status=20f=C3=BCr=20Bilder,=20?= =?UTF-8?q?Auto-Trigger,=20=C3=9Cbersetzungen,=20Vertonbarkeits-Regel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pictures: reviewed-Status (Constraint + ALLOWED_STATUSES + Auto-Trigger beim Object-Linking) - objects: STATUSES um reviewed erweitert; Auto-Trigger draft→reviewed wenn Pair verlinkt - pairs/statements/questions: STATUSES um reviewed (Phase-1-Lücke) - pairs: POST /:id/review kaskadiert Pair+Frage+Statements (verlangt alle 3 Sprachen) - words: Auto requested→translated wenn alle titel_* gefüllt (POST+PATCH) - audios computeUnits: nur vertonbar wenn ALLE 3 Sprachen pro Feld gefüllt - claude: translate-text/translate-row/translate-missing mit Placeholder-Schutz (⟦PHn:label⟧-Tokenisierung, Label übersetzt, UUID erhalten); translation-coverage Co-Authored-By: Claude Opus 4.8 --- src/db-migrate.js | 5 +- src/routes/audios.js | 31 +++-- src/routes/claude.js | 240 +++++++++++++++++++++++++++++++++++++++ src/routes/objects.js | 12 +- src/routes/pairs.js | 51 ++++++++- src/routes/pictures.js | 2 +- src/routes/questions.js | 2 +- src/routes/statements.js | 2 +- src/routes/words.js | 22 +++- 9 files changed, 346 insertions(+), 21 deletions(-) diff --git a/src/db-migrate.js b/src/db-migrate.js index c1dfc09..d000ff9 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -17,7 +17,7 @@ async function migrate() { CREATE TABLE IF NOT EXISTS pictures ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'uploaded' - CHECK (status IN ('uploaded', 'published', 'blocked')), + CHECK (status IN ('uploaded', 'reviewed', 'published', 'blocked')), blocked_reason VARCHAR(20) CHECK (blocked_reason IN ('regenerate', 'not_to_use')), generation_prompt TEXT, generation_timestamp TIMESTAMPTZ, @@ -39,6 +39,9 @@ async function migrate() { $$ LANGUAGE plpgsql `); + await query(`ALTER TABLE pictures DROP CONSTRAINT IF EXISTS pictures_status_check`).catch(() => {}); + await query(`ALTER TABLE pictures ADD CONSTRAINT pictures_status_check CHECK (status IN ('uploaded', 'reviewed', 'published', 'blocked'))`).catch(() => {}); + await query(` DROP TRIGGER IF EXISTS pictures_updated_at ON pictures; CREATE TRIGGER pictures_updated_at diff --git a/src/routes/audios.js b/src/routes/audios.js index 5c19040..b323f7c 100644 --- a/src/routes/audios.js +++ b/src/routes/audios.js @@ -95,39 +95,46 @@ async function generateAndStore({ text, voice_id, language, model_id, speed, sta } // ── Fehlende/vorhandene Audio-Einheiten für einen Filter berechnen ─────────── +// Regel: Eine Quell-Zeile+Feld gilt erst dann als vertonbar, wenn ALLE drei Sprachen Text haben. +// Wenn nur eine Sprache angefragt ist (Filter), wird trotzdem auf Vollständigkeit aller Sprachen geprüft. async function computeUnits({ source_table, language }) { const tables = source_table ? [source_table] : Object.keys(SOURCE_CONFIG); - const langs = language ? [language] : LANGS; + const filterLangs = language ? [language] : LANGS; const units = []; for (const table of tables) { const cfg = SOURCE_CONFIG[table]; if (!cfg) continue; + // Immer ALLE Sprachen laden, um Vollständigkeit prüfen zu können. const cols = []; - for (const f of cfg.fields) for (const l of langs) cols.push(`${f}_${l}`); + for (const f of cfg.fields) for (const l of LANGS) cols.push(`${f}_${l}`); const rows = (await query( `SELECT id, ${cols.join(', ')} FROM ${table} WHERE status = ANY($1)`, [cfg.ready] )).rows; - // Vorhandene Audios dieser Tabelle (für die gefragten Sprachen) + // Vorhandene Audios dieser Tabelle const have = new Set(); const audioRows = (await query( `SELECT source_id, source_field, language FROM audios WHERE source_table = $1 AND language = ANY($2) AND source_id IS NOT NULL`, - [table, langs] + [table, filterLangs] )).rows; audioRows.forEach(a => have.add(`${a.source_id}|${a.source_field}|${a.language}`)); for (const row of rows) { - for (const f of cfg.fields) for (const l of langs) { - const text = row[`${f}_${l}`]; - if (!text || !text.trim()) continue; - units.push({ - source_table: table, source_id: row.id, source_field: f, language: l, - text: text.trim(), - hasAudio: have.has(`${row.id}|${f}|${l}`), - }); + for (const f of cfg.fields) { + // Vollständigkeitscheck pro Feld: alle 3 Sprachen müssen Text haben + const allFilled = LANGS.every(l => (row[`${f}_${l}`] || '').trim()); + if (!allFilled) continue; + // Pro angefragter Sprache eine Audio-Einheit erzeugen + for (const l of filterLangs) { + const text = (row[`${f}_${l}`] || '').trim(); + units.push({ + source_table: table, source_id: row.id, source_field: f, language: l, + text, hasAudio: have.has(`${row.id}|${f}|${l}`), + }); + } } } } diff --git a/src/routes/claude.js b/src/routes/claude.js index 7d270fb..6580b53 100644 --- a/src/routes/claude.js +++ b/src/routes/claude.js @@ -1,6 +1,96 @@ const router = require('express').Router(); +const { query } = require('../db'); const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; +const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'; +const LANGS = ['de', 'en', 'sv']; + +const LANG_LABEL = { de: 'Deutsch', en: 'English', sv: 'Svenska' }; + +// ── Placeholder-Schutz ──────────────────────────────────────────────────────── +// Format im Quelltext: {{label.w:uuid}} oder {{label.o:uuid}} +const PLACEHOLDER_RE = /\{\{([^.{}]+)\.(w|o):([0-9a-f-]{36})\}\}/g; + +// Sätze für Claude vorbereiten: jedes Placeholder durch ⟦PHn:label⟧-Token ersetzen. +// Token-Format ist absichtlich exotisch, damit Claude es nicht versehentlich ändert. +function tokenize(text) { + const tokens = []; + let i = 0; + const tokenized = text.replace(PLACEHOLDER_RE, (_, label, type, uuid) => { + const safeLabel = String(label).replace(/[⟦⟧:]/g, ' ').trim(); + const key = `PH${i++}`; + tokens.push({ key, uuid, type, sourceLabel: label }); + return `⟦${key}:${safeLabel}⟧`; + }); + return { tokenized, tokens }; +} + +// Rückbau: aus Claude-Antwort wieder {{label.type:uuid}} machen. +// Erwartet `labels: { PH0: 'apple', ... }` aus dem JSON-Response. +function detokenize(translated, tokens, labelsFromClaude) { + let out = translated; + const seen = new Set(); + for (const t of tokens) { + const label = (labelsFromClaude && labelsFromClaude[t.key]) || t.sourceLabel; + // Token-Form im Text kann ⟦PH0:irgendwas⟧ sein — wir matchen über die Key + const re = new RegExp(`⟦${t.key}:[^⟧]*⟧`, 'g'); + let replaced = false; + out = out.replace(re, () => { replaced = true; seen.add(t.key); return `{{${label}.${t.type}:${t.uuid}}}`; }); + if (!replaced) { + // Notfall: Token nicht zurückgekommen → an Ende hängen, damit nichts verloren geht + out += ` {{${label}.${t.type}:${t.uuid}}}`; + seen.add(t.key); + } + } + return { text: out, missingTokens: tokens.filter(t => !seen.has(t.key)).map(t => t.key) }; +} + +async function callClaude({ system, user, maxTokens = 2000 }) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { const e = new Error('ANTHROPIC_API_KEY nicht konfiguriert'); e.status = 500; throw e; } + const res = await fetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ + model: ANTHROPIC_MODEL, max_tokens: maxTokens, system, + messages: [{ role: 'user', content: user }], + }), + }); + if (!res.ok) { const err = await res.json().catch(() => ({})); const e = new Error(err.error?.message || `Claude API ${res.status}`); e.status = res.status; throw e; } + const data = await res.json(); + let raw = data.content[0].text.trim(); + const md = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/); + if (md) raw = md[1]; + return JSON.parse(raw); +} + +// Übersetzt einen Text inkl. Placeholder-Schutz. +async function translateText({ text, from, to }) { + if (!text || !text.trim()) return ''; + const { tokenized, tokens } = tokenize(text); + const system = 'Du bist ein professioneller Übersetzer. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown, ohne Erklärungen.'; + const user = `Übersetze diesen Text von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` + + `WICHTIG: Tokens der Form ⟦PHn:wort⟧ sind Platzhalter. Übersetze NUR das Wort innerhalb des Tokens, ` + + `behalte das Token-Format exakt bei (⟦PHn:übersetztesWort⟧). Passe die Beugung des Wortes an den umgebenden Satz an ` + + `(Mehrzahl/Kasus). Die Token-Reihenfolge im Satz darfst du frei wählen wie es natürlich klingt.\n\n` + + `Quelltext:\n${tokenized}\n\n` + + `Antwort-Format:\n{"translated":"...","labels":{${tokens.map(t => `"${t.key}":"<übersetztes Wort>"`).join(',')}}}`; + + const data = await callClaude({ system, user }); + if (typeof data.translated !== 'string') throw new Error('Ungültiges JSON: translated fehlt'); + const { text: detok } = detokenize(data.translated, tokens, data.labels || {}); + return detok; +} + +// ── Auto-Status für Wörter (Spiegel zum Trigger in words.js) ────────────────── +async function maybeAutoTranslated(wordId) { + const r = await query(`SELECT titel_de, titel_en, titel_sv, status FROM words WHERE id = $1`, [wordId]); + const w = r.rows[0]; + if (!w) return; + if (w.titel_de && w.titel_en && w.titel_sv && w.status === 'requested') + await query(`UPDATE words SET status='translated' WHERE id=$1`, [wordId]); +} + // POST /api/claude/generate-pairs // Body: { imageUrl, objects: [{id, words: [{titel_de, titel_en}], selections: [{points:[{x,y}]}]}, ...], selectedObjectId } @@ -132,4 +222,154 @@ router.post('/generate-words', async (req, res, next) => { } }); +// ── Übersetzungs-Konfiguration pro Tabelle ──────────────────────────────────── +const TRANSLATE_CONFIG = { + words: { fields: ['titel'] }, + questions: { fields: ['sentence'] }, + statements: { fields: ['positive_sentence', 'negative_sentence'] }, +}; + +// POST /api/claude/translate-text — generischer Übersetzungs-Primitive +router.post('/translate-text', async (req, res, next) => { + try { + const { text, from, to } = req.body; + if (!text) return res.status(400).json({ error: 'text fehlt' }); + if (!LANGS.includes(from)) return res.status(400).json({ error: `from muss eine von: ${LANGS.join(', ')} sein` }); + if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` }); + if (from === to) return res.status(400).json({ error: 'from und to müssen unterschiedlich sein' }); + + const translated = await translateText({ text, from, to }); + res.json({ translated }); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +// POST /api/claude/translate-row — eine konkrete Zeile übersetzen + speichern +router.post('/translate-row', async (req, res, next) => { + try { + const { source_table, source_id, to } = req.body; + let { from } = req.body; + const cfg = TRANSLATE_CONFIG[source_table]; + if (!cfg) return res.status(400).json({ error: `source_table muss eine von: ${Object.keys(TRANSLATE_CONFIG).join(', ')} sein` }); + if (!source_id) return res.status(400).json({ error: 'source_id fehlt' }); + if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` }); + + // Zeile laden + const cols = cfg.fields.flatMap(f => LANGS.map(l => `${f}_${l}`)); + const r = await query(`SELECT ${cols.join(', ')} FROM ${source_table} WHERE id = $1`, [source_id]); + if (!r.rows.length) return res.status(404).json({ error: 'source row not found' }); + const row = r.rows[0]; + + // Quell-Sprache: explizit angegeben, sonst erste gefüllte ≠ to + if (!from) { + for (const f of cfg.fields) for (const l of LANGS) + if (l !== to && (row[`${f}_${l}`] || '').trim()) { from = l; break; } + if (!from) return res.status(400).json({ error: 'Keine Quellsprache mit Inhalt gefunden' }); + } + if (from === to) return res.status(400).json({ error: 'from und to müssen unterschiedlich sein' }); + + // Pro Feld übersetzen (überspringt leere Quell-Felder) + const updates = {}; + for (const field of cfg.fields) { + const src = (row[`${field}_${from}`] || '').trim(); + if (!src) continue; + updates[`${field}_${to}`] = await translateText({ text: src, from, to }); + } + if (!Object.keys(updates).length) + return res.status(400).json({ error: `Keine Quell-Texte in ${from} vorhanden` }); + + const fields = Object.keys(updates); + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => updates[f]), source_id]; + const upd = await query( + `UPDATE ${source_table} SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`, + values + ); + + if (source_table === 'words') await maybeAutoTranslated(source_id); + + res.json({ ...upd.rows[0], translated_fields: fields }); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +// POST /api/claude/translate-missing — alle Zeilen einer Tabelle in `to` übersetzen, +// wo `to` leer aber mindestens eine andere Sprache gefüllt ist. +router.post('/translate-missing', async (req, res, next) => { + try { + const { source_table, to, from } = req.body; + const cfg = TRANSLATE_CONFIG[source_table]; + if (!cfg) return res.status(400).json({ error: `source_table muss eine von: ${Object.keys(TRANSLATE_CONFIG).join(', ')} sein` }); + if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` }); + + const cols = cfg.fields.flatMap(f => LANGS.map(l => `${f}_${l}`)); + // Zielsprache leer für mindestens ein Feld UND Quell-Sprache(n) gefüllt + const missingCond = cfg.fields.map(f => `(${f}_${to} IS NULL OR ${f}_${to} = '')`).join(' OR '); + const sourceCond = cfg.fields.map(f => LANGS.filter(l => l !== to) + .map(l => `(${f}_${l} IS NOT NULL AND ${f}_${l} <> '')`).join(' OR ')).join(' OR '); + const rows = (await query( + `SELECT id, ${cols.join(', ')} FROM ${source_table} + WHERE (${missingCond}) AND (${sourceCond}) + LIMIT 200` + )).rows; + + const results = { translated: 0, failed: 0, errors: [] }; + for (const row of rows) { + try { + // Quell-Sprache pro Zeile bestimmen + let f = from; + if (!f) { + for (const lang of LANGS) + if (lang !== to && cfg.fields.some(field => (row[`${field}_${lang}`] || '').trim())) { f = lang; break; } + } + if (!f) { results.failed++; results.errors.push({ id: row.id, error: 'keine Quellsprache' }); continue; } + + const updates = {}; + for (const field of cfg.fields) { + const tgt = (row[`${field}_${to}`] || '').trim(); + const src = (row[`${field}_${f}`] || '').trim(); + if (tgt || !src) continue; + updates[`${field}_${to}`] = await translateText({ text: src, from: f, to }); + } + if (Object.keys(updates).length) { + const fields = Object.keys(updates); + const setClauses = fields.map((c, i) => `${c} = $${i + 1}`).join(', '); + await query(`UPDATE ${source_table} SET ${setClauses} WHERE id = $${fields.length + 1}`, + [...fields.map(c => updates[c]), row.id]); + if (source_table === 'words') await maybeAutoTranslated(row.id); + results.translated++; + } + } catch (err) { + results.failed++; + results.errors.push({ id: row.id, error: err.message }); + } + } + res.json(results); + } catch (err) { next(err); } +}); + +// GET /api/claude/translation-coverage — wie viele Zeilen pro Tabelle×Sprache haben Text +router.get('/translation-coverage', async (req, res, next) => { + try { + const coverage = []; + for (const [table, cfg] of Object.entries(TRANSLATE_CONFIG)) { + // Eine Zeile zählt als "in Sprache vorhanden", wenn ALLE konfigurierten Felder gefüllt sind. + // (für statements: pos+neg — wir nehmen einfach pos als Haupt-Indikator, neg ist optional) + const mainField = cfg.fields[0]; + for (const lang of LANGS) { + const totalRow = (await query(`SELECT COUNT(*)::int AS c FROM ${table}`)).rows[0]; + const haveRow = (await query( + `SELECT COUNT(*)::int AS c FROM ${table} WHERE ${mainField}_${lang} IS NOT NULL AND ${mainField}_${lang} <> ''` + )).rows[0]; + coverage.push({ source_table: table, language: lang, total: totalRow.c, have: haveRow.c, missing: totalRow.c - haveRow.c }); + } + } + res.json({ coverage }); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/src/routes/objects.js b/src/routes/objects.js index 04ba0af..87b74a6 100644 --- a/src/routes/objects.js +++ b/src/routes/objects.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const { query } = require('../db'); -const STATUSES = ['draft', 'blocked', 'published']; +const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const STATUS_TIMESTAMP = { published: 'published_at', @@ -216,6 +216,13 @@ 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]); + // Auto-Status: Bild → reviewed wenn vorher uploaded (Mensch hat Objekt darauf erstellt = geprüft) + await query( + `UPDATE pictures SET status = 'reviewed', objects_created = true, + objects_created_at = COALESCE(objects_created_at, NOW()) + WHERE id = $1 AND status = 'uploaded'`, + [req.params.pictureId] + ); res.status(204).end(); } catch (err) { next(err); } }); @@ -248,6 +255,9 @@ 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]); + // Auto-Status: Objekt → reviewed wenn vorher draft (Pair erstellt = Inhalt befüllt) + await query(`UPDATE objects SET status = 'reviewed' WHERE id = $1 AND status = 'draft'`, + [req.params.id]); res.status(204).end(); } catch (err) { next(err); } }); diff --git a/src/routes/pairs.js b/src/routes/pairs.js index 6ba1c9b..2e9ef4d 100644 --- a/src/routes/pairs.js +++ b/src/routes/pairs.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const { query } = require('../db'); -const STATUSES = ['draft', 'blocked', 'published']; +const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']); function validateAnswerType(val) { @@ -214,6 +214,55 @@ router.patch('/:id', async (req, res, next) => { } catch (err) { next(err); } }); +// POST /api/pairs/:id/review — setzt Pair + verlinkte Frage + Statements auf 'reviewed' +// Voraussetzung: alle 3 Sprachen sind in den verwendeten Feldern gefüllt. +router.post('/:id/review', async (req, res, next) => { + try { + const pr = await query( + `SELECT id, question_id, positive_statement_id, negative_statement_id, status + FROM pairs WHERE id = $1`, [req.params.id]); + if (!pr.rows.length) return res.status(404).json({ error: 'Not found' }); + const p = pr.rows[0]; + + // Vollständigkeit der 3 Sprachen prüfen + const missing = []; + if (p.question_id) { + const q = (await query( + `SELECT sentence_de, sentence_en, sentence_sv FROM questions WHERE id = $1`, [p.question_id])).rows[0]; + if (!q) return res.status(409).json({ error: 'Verknüpfte Frage fehlt' }); + for (const l of LANGS) if (!(q[`sentence_${l}`] || '').trim()) missing.push(`Frage ${l}`); + } + if (p.positive_statement_id) { + const s = (await query( + `SELECT positive_sentence_de, positive_sentence_en, positive_sentence_sv FROM statements WHERE id = $1`, + [p.positive_statement_id])).rows[0]; + if (!s) return res.status(409).json({ error: 'Positiv-Statement fehlt' }); + for (const l of LANGS) if (!(s[`positive_sentence_${l}`] || '').trim()) missing.push(`Positiv ${l}`); + } + if (p.negative_statement_id) { + const s = (await query( + `SELECT negative_sentence_de, negative_sentence_en, negative_sentence_sv FROM statements WHERE id = $1`, + [p.negative_statement_id])).rows[0]; + // Negativ nur prüfen wenn überhaupt in mindestens einer Sprache befüllt + const hasAny = s && LANGS.some(l => (s[`negative_sentence_${l}`] || '').trim()); + if (hasAny) for (const l of LANGS) if (!(s[`negative_sentence_${l}`] || '').trim()) missing.push(`Negativ ${l}`); + } + + if (missing.length) + return res.status(409).json({ error: 'Übersetzung unvollständig', missing }); + + // Kaskadierend reviewed (außer wenn schon published) + if (p.question_id) + await query(`UPDATE questions SET status='reviewed' WHERE id=$1 AND status='draft'`, [p.question_id]); + const stmtIds = [p.positive_statement_id, p.negative_statement_id].filter(Boolean); + if (stmtIds.length) + await query(`UPDATE statements SET status='reviewed' WHERE id = ANY($1) AND status='draft'`, [stmtIds]); + const upd = await query( + `UPDATE pairs SET status='reviewed' WHERE id=$1 RETURNING *`, [p.id]); + res.json(upd.rows[0]); + } catch (err) { next(err); } +}); + // POST /api/pairs/:id/publish?lang=sv — validiert Readiness und veröffentlicht kaskadierend router.post('/:id/publish', async (req, res, next) => { try { diff --git a/src/routes/pictures.js b/src/routes/pictures.js index ce7833b..c21fa6c 100644 --- a/src/routes/pictures.js +++ b/src/routes/pictures.js @@ -6,7 +6,7 @@ const { uploadFile, deleteFile, keyFromUrl } = require('../s3'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } }); -const ALLOWED_STATUSES = ['uploaded', 'published', 'blocked']; +const ALLOWED_STATUSES = ['uploaded', 'reviewed', 'published', 'blocked']; const ALLOWED_BLOCKED_REASONS = ['regenerate', 'not_to_use']; // GET /api/pictures diff --git a/src/routes/questions.js b/src/routes/questions.js index c238f51..0173cc3 100644 --- a/src/routes/questions.js +++ b/src/routes/questions.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const { query } = require('../db'); -const STATUSES = ['draft', 'blocked', 'published']; +const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const STATUS_TIMESTAMP = { published: 'published_at', blocked: 'blocked_at' }; // GET /api/questions diff --git a/src/routes/statements.js b/src/routes/statements.js index 3d5936f..ce70daf 100644 --- a/src/routes/statements.js +++ b/src/routes/statements.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const { query } = require('../db'); -const STATUSES = ['draft', 'blocked', 'published']; +const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const STATUS_TIMESTAMP = { published: 'published_at', blocked: 'blocked_at' }; async function getWithRelations(id) { diff --git a/src/routes/words.js b/src/routes/words.js index 9c93dcd..fb2a887 100644 --- a/src/routes/words.js +++ b/src/routes/words.js @@ -42,16 +42,24 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err); } }); +// Hilfsfunktion: wenn alle 3 Sprachen gefüllt sind und Status `requested`, auto → `translated`. +function autoTranslatedStatus(row) { + return (row.titel_de && row.titel_en && row.titel_sv && row.status === 'requested') ? 'translated' : null; +} + // POST /api/words router.post('/', async (req, res, next) => { try { const { titel_de, titel_en, titel_sv, difficulty_level, status } = req.body; if (status && !STATUSES.includes(status)) return res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` }); + // Auto: alle 3 Sprachen direkt mitgeliefert + kein expliziter Status → 'translated' + const allLangs = titel_de && titel_en && titel_sv; + const effectiveStatus = status || (allLangs ? 'translated' : 'requested'); const result = await query( `INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, status, requested_at) - VALUES ($1, $2, $3, $4, COALESCE($5, 'requested'), NOW()) RETURNING *`, - [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null, status || null] + VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING *`, + [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null, effectiveStatus] ); res.status(201).json({ ...result.rows[0], picture_ids: [], category_ids: [] }); } catch (err) { next(err); } @@ -82,7 +90,15 @@ router.patch('/:id', async (req, res, next) => { values ); if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); - res.json(result.rows[0]); + + // Auto-Übergang: requested → translated wenn jetzt alle 3 Sprachen gefüllt sind + let row = result.rows[0]; + const next = autoTranslatedStatus(row); + if (next) { + const upd = await query(`UPDATE words SET status = $1 WHERE id = $2 RETURNING *`, [next, row.id]); + row = upd.rows[0]; + } + res.json(row); } catch (err) { next(err); } });