feat: reviewed-Status für Bilder, Auto-Trigger, Übersetzungen, Vertonbarkeits-Regel

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 07:35:37 +02:00
parent 6c74aabc3f
commit a3ff787259
9 changed files with 346 additions and 21 deletions

View File

@@ -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

View File

@@ -95,42 +95,49 @@ 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;
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: text.trim(),
hasAudio: have.has(`${row.id}|${f}|${l}`),
text, hasAudio: have.has(`${row.id}|${f}|${l}`),
});
}
}
}
}
return units;
}

View File

@@ -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;

View File

@@ -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); }
});

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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); }
});