diff --git a/.env.example b/.env.example index 77bbcc0..d872490 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,8 @@ ANTHROPIC_API_KEY=sk-ant-... # ElevenLabs (für TTS-Generierung) ELEVENLABS_API_KEY=sk_... +# Default-Stimme pro Sprache (ElevenLabs voice_id). Fällt auf ELEVENLABS_VOICE_DEFAULT zurück. +ELEVENLABS_VOICE_DEFAULT=XXCqsM8I9KhqA7jLGj1U +ELEVENLABS_VOICE_DE= +ELEVENLABS_VOICE_EN= +ELEVENLABS_VOICE_SV= diff --git a/src/db-migrate.js b/src/db-migrate.js index ecab850..1e31808 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -132,7 +132,7 @@ async function migrate() { CREATE TABLE IF NOT EXISTS questions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft', 'blocked', 'published')), + CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')), sentence_de TEXT, sentence_en TEXT, sentence_sv TEXT, @@ -152,7 +152,7 @@ async function migrate() { for (const col of questionCols) await query(`ALTER TABLE questions ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); await query(`ALTER TABLE questions DROP CONSTRAINT IF EXISTS questions_status_check`).catch(() => {}); - await query(`ALTER TABLE questions ADD CONSTRAINT questions_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {}); + await query(`ALTER TABLE questions ADD CONSTRAINT questions_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {}); await query(` DROP TRIGGER IF EXISTS questions_updated_at ON questions; @@ -165,7 +165,7 @@ async function migrate() { CREATE TABLE IF NOT EXISTS statements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft', 'blocked', 'published')), + CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')), negative_sentence_de TEXT, negative_sentence_en TEXT, negative_sentence_sv TEXT, @@ -189,7 +189,7 @@ async function migrate() { 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(`ALTER TABLE statements ADD CONSTRAINT statements_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {}); await query(` DROP TRIGGER IF EXISTS statements_updated_at ON statements; @@ -221,7 +221,7 @@ async function migrate() { 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')), + CHECK (status IN ('draft', 'reviewed', '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')), @@ -253,7 +253,7 @@ async function migrate() { 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 ADD CONSTRAINT pairs_status_check CHECK (status IN ('draft', 'reviewed', '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(() => {}); @@ -274,7 +274,7 @@ async function migrate() { CREATE TABLE IF NOT EXISTS objects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft', 'blocked', 'published')), + CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')), selections JSONB, notes TEXT, blocked_topic TEXT, @@ -285,6 +285,9 @@ async function migrate() { ) `); + await query(`ALTER TABLE objects DROP CONSTRAINT IF EXISTS objects_status_check`).catch(() => {}); + await query(`ALTER TABLE objects ADD CONSTRAINT objects_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {}); + await query(` DROP TRIGGER IF EXISTS objects_updated_at ON objects; CREATE TRIGGER objects_updated_at @@ -474,6 +477,11 @@ async function migrate() { await query(`ALTER TABLE users_public ADD CONSTRAINT users_public_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`).catch(() => {}); await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_public_user_id_idx ON users_public (user_id) WHERE user_id IS NOT NULL`); + // Gamification: EP-Total, Streak, letzter Übungstag + await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS total_ep INTEGER NOT NULL DEFAULT 0`).catch(() => {}); + await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS streak_days INTEGER NOT NULL DEFAULT 0`).catch(() => {}); + await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS last_practice_at TIMESTAMPTZ`).catch(() => {}); + // Seed languages (de exists, add en + sv) // Full unique constraint (not partial) so ON CONFLICT works cleanly await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en)`).catch(() => {}); @@ -507,6 +515,8 @@ async function migrate() { ) `); + await query(`ALTER TABLE user_pair_progress ADD COLUMN IF NOT EXISTS earned_points INTEGER NOT NULL DEFAULT 0`).catch(() => {}); + await query(` CREATE OR REPLACE FUNCTION update_last_seen_at() RETURNS TRIGGER AS $$ @@ -543,6 +553,18 @@ async function migrate() { ) `); + // Verknüpfung Audio → Quelle (Wort/Frage/Statement) + Sprache. + // source_field: 'titel' | 'sentence' | 'positive_sentence' | 'negative_sentence' + await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_table TEXT`).catch(() => {}); + await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_id UUID`).catch(() => {}); + await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_field TEXT`).catch(() => {}); + await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS language VARCHAR(10)`).catch(() => {}); + await query(` + CREATE UNIQUE INDEX IF NOT EXISTS audios_source_uq + ON audios (source_table, source_id, source_field, language) + WHERE source_table IS NOT NULL + `).catch(() => {}); + await query(` DROP TRIGGER IF EXISTS audios_updated_at ON audios; CREATE TRIGGER audios_updated_at diff --git a/src/routes/audios.js b/src/routes/audios.js index f61940d..30d4535 100644 --- a/src/routes/audios.js +++ b/src/routes/audios.js @@ -2,18 +2,129 @@ const router = require('express').Router(); const { v4: uuidv4 } = require('uuid'); const { query } = require('../db'); const { uploadFile, deleteFile, keyFromUrl } = require('../s3'); +const { voiceForLanguage } = require('../voices'); const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1'; const ALLOWED_STATUSES = ['generated', 'published', 'blocked']; +const LANGS = ['de', 'en', 'sv']; + +// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie "reif" sind. +// column = `${field}_${lang}` (z.B. titel_de, sentence_sv, positive_sentence_en) +const SOURCE_CONFIG = { + words: { fields: ['titel'], ready: ['generated', 'published'] }, + questions: { fields: ['sentence'], ready: ['reviewed', 'published'] }, + statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['reviewed', 'published'] }, +}; + +// ── ElevenLabs aufrufen + in S3/DB ablegen ────────────────────────────────── +async function generateAndStore({ text, voice_id, language, model_id, speed, stability, + similarity_boost, style, source_table, source_id, source_field }) { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { const e = new Error('ELEVENLABS_API_KEY not configured'); e.status = 500; throw e; } + + const voice = voice_id || voiceForLanguage(language); + const m = model_id || 'eleven_multilingual_v2'; + const sp = speed ?? 1.0, st = stability ?? 0.5, sb = similarity_boost ?? 0.75, sy = style ?? 0.0; + + const elevenRes = await fetch( + `${ELEVENLABS_BASE}/text-to-speech/${voice}/with-timestamps`, + { + method: 'POST', + headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, model_id: m, speed: sp, + voice_settings: { stability: st, similarity_boost: sb, style: sy, use_speaker_boost: true }, + }), + } + ); + if (!elevenRes.ok) { + const detail = await elevenRes.text(); + const e = new Error('ElevenLabs error'); e.status = elevenRes.status; e.detail = detail; throw e; + } + + const { audio_base64, alignment } = await elevenRes.json(); + const buffer = Buffer.from(audio_base64, 'base64'); + const id = uuidv4(); + const key = `audios/${id}/${uuidv4()}.mp3`; + const audio_link = await uploadFile(key, buffer, 'audio/mpeg'); + + // Bestehendes Audio derselben Quelle ersetzen (idempotent dank unique index) + if (source_table && source_id && source_field && language) { + const old = await query( + `SELECT id, audio_link FROM audios + WHERE source_table = $1 AND source_id = $2 AND source_field = $3 AND language = $4`, + [source_table, source_id, source_field, language] + ); + for (const row of old.rows) { + const k = keyFromUrl(row.audio_link); + if (k) await deleteFile(k).catch(() => {}); + await query('DELETE FROM audios WHERE id = $1', [row.id]); + } + } + + const result = await query( + `INSERT INTO audios + (id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style, + source_table, source_id, source_field, language) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING *`, + [id, text, audio_link, JSON.stringify(alignment), voice, m, sp, st, sb, sy, + source_table || null, source_id || null, source_field || null, language || null] + ); + return result.rows[0]; +} + +// ── Fehlende/vorhandene Audio-Einheiten für einen Filter berechnen ─────────── +async function computeUnits({ source_table, language }) { + const tables = source_table ? [source_table] : Object.keys(SOURCE_CONFIG); + const langs = language ? [language] : LANGS; + const units = []; + + for (const table of tables) { + const cfg = SOURCE_CONFIG[table]; + if (!cfg) continue; + const cols = []; + 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) + 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] + )).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}`), + }); + } + } + } + return units; +} // GET /api/audios router.get('/', async (req, res, next) => { try { - const { status, voice_id, limit = 50, offset = 0 } = req.query; + const { status, voice_id, source_table, source_id, language, limit = 50, offset = 0 } = req.query; const params = [Math.min(parseInt(limit), 500), parseInt(offset)]; const conditions = []; - if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); } - if (voice_id) { conditions.push(`voice_id = $${params.length + 1}`); params.push(voice_id); } + if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); } + if (voice_id) { conditions.push(`voice_id = $${params.length + 1}`); params.push(voice_id); } + if (source_table) { conditions.push(`source_table = $${params.length + 1}`); params.push(source_table); } + if (source_id) { conditions.push(`source_id = $${params.length + 1}`); params.push(source_id); } + if (language) { conditions.push(`language = $${params.length + 1}`); params.push(language); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await query( `SELECT * FROM audios ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, @@ -23,6 +134,31 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err); } }); +// GET /api/audios/coverage?source_table=questions&language=sv +// Aggregierte Abdeckung + Liste fehlender Einheiten. +router.get('/coverage', async (req, res, next) => { + try { + const { source_table, language } = req.query; + const units = await computeUnits({ source_table, language }); + + const byGroup = {}; + for (const u of units) { + const key = `${u.source_table}|${u.language}`; + byGroup[key] ??= { source_table: u.source_table, language: u.language, total: 0, withAudio: 0 }; + byGroup[key].total++; + if (u.hasAudio) byGroup[key].withAudio++; + } + const coverage = Object.values(byGroup).map(g => ({ + ...g, missing: g.total - g.withAudio, + })); + const missingUnits = units + .filter(u => !u.hasAudio) + .map(({ hasAudio, ...rest }) => rest); + + res.json({ coverage, missingUnits }); + } catch (err) { next(err); } +}); + // GET /api/audios/:id router.get('/:id', async (req, res, next) => { try { @@ -32,72 +168,85 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err); } }); -// POST /api/audios/generate — ElevenLabs TTS → S3 → DB +// POST /api/audios/generate — ElevenLabs TTS → S3 → DB (optional mit Quell-Verknüpfung) router.post('/generate', async (req, res, next) => { try { - const { - text, - voice_id, - model_id = 'eleven_multilingual_v2', - speed = 1.0, - stability = 0.5, - similarity_boost = 0.75, - style = 0.0, - } = req.body; + const { text, voice_id, language, source_table, source_id, source_field, + model_id, speed, stability, similarity_boost, style } = req.body; + if (!text) return res.status(400).json({ error: 'text is required' }); + if (!voice_id && !language) + return res.status(400).json({ error: 'voice_id or language is required' }); - if (!text) return res.status(400).json({ error: 'text is required' }); - if (!voice_id) return res.status(400).json({ error: 'voice_id is required' }); + const row = await generateAndStore({ + text, voice_id, language, model_id, speed, stability, similarity_boost, style, + source_table, source_id, source_field, + }); + res.status(201).json(row); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message, detail: err.detail }); + next(err); + } +}); - const apiKey = process.env.ELEVENLABS_API_KEY; - if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' }); +// POST /api/audios/generate-for — Text aus der Quell-Zeile auflösen, dann generieren +router.post('/generate-for', async (req, res, next) => { + try { + const { source_table, source_id, source_field, language } = req.body; + const cfg = SOURCE_CONFIG[source_table]; + if (!cfg) return res.status(400).json({ error: `source_table must be one of: ${Object.keys(SOURCE_CONFIG).join(', ')}` }); + if (!cfg.fields.includes(source_field)) return res.status(400).json({ error: `source_field must be one of: ${cfg.fields.join(', ')}` }); + if (!LANGS.includes(language)) return res.status(400).json({ error: `language must be one of: ${LANGS.join(', ')}` }); + if (!source_id) return res.status(400).json({ error: 'source_id is required' }); - const elevenRes = await fetch( - `${ELEVENLABS_BASE}/text-to-speech/${voice_id}/with-timestamps`, - { - method: 'POST', - headers: { - 'xi-api-key': apiKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - text, - model_id, - speed, - voice_settings: { stability, similarity_boost, style, use_speaker_boost: true }, - }), - } - ); + const col = `${source_field}_${language}`; + const r = await query(`SELECT ${col} AS text FROM ${source_table} WHERE id = $1`, [source_id]); + if (!r.rows.length) return res.status(404).json({ error: 'source row not found' }); + const text = (r.rows[0].text || '').trim(); + if (!text) return res.status(400).json({ error: `${col} is empty` }); - if (!elevenRes.ok) { - const err = await elevenRes.text(); - return res.status(elevenRes.status).json({ error: 'ElevenLabs error', detail: err }); + const row = await generateAndStore({ text, language, source_table, source_id, source_field }); + res.status(201).json(row); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message, detail: err.detail }); + next(err); + } +}); + +// POST /api/audios/generate-batch — alle fehlenden Audios für {source_table, language} generieren +router.post('/generate-batch', async (req, res, next) => { + try { + const { source_table, language, units } = req.body; + let todo; + if (Array.isArray(units) && units.length) { + todo = units; + } else { + todo = (await computeUnits({ source_table, language })).filter(u => !u.hasAudio); } - const { audio_base64, alignment } = await elevenRes.json(); - const buffer = Buffer.from(audio_base64, 'base64'); - - const id = uuidv4(); - const key = `audios/${id}/${uuidv4()}.mp3`; - const audio_link = await uploadFile(key, buffer, 'audio/mpeg'); - - const result = await query( - `INSERT INTO audios - (id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING *`, - [id, text, audio_link, JSON.stringify(alignment), voice_id, model_id, - speed, stability, similarity_boost, style] - ); - - res.status(201).json(result.rows[0]); + const results = { generated: 0, failed: 0, errors: [] }; + for (const u of todo) { + try { + await generateAndStore({ + text: u.text, language: u.language, + source_table: u.source_table, source_id: u.source_id, source_field: u.source_field, + }); + results.generated++; + } catch (err) { + results.failed++; + results.errors.push({ source_id: u.source_id, field: u.source_field, lang: u.language, error: err.message }); + } + } + res.json(results); } catch (err) { next(err); } }); -// PATCH /api/audios/:id — Status ändern +// PATCH /api/audios/:id — Status / Metadaten ändern router.patch('/:id', async (req, res, next) => { try { const allowed = ['status', 'text', 'audio_link', 'alignment', 'voice_id', 'model_id', - 'speed', 'stability', 'similarity_boost', 'style', 'published_at', 'blocked_at']; + 'speed', 'stability', 'similarity_boost', 'style', + 'source_table', 'source_id', 'source_field', 'language', + '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' }); diff --git a/src/routes/auth.js b/src/routes/auth.js index 728def0..98b607a 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -136,6 +136,9 @@ router.get('/me', requireJwt, async (req, res, next) => { const r = await query( `SELECT u.id, u.email, u.role, un.username, + COALESCE(up.total_ep, 0) AS total_ep, + COALESCE(up.streak_days, 0) AS streak_days, + up.last_practice_at, ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel, lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel FROM users u @@ -147,7 +150,54 @@ router.get('/me', requireJwt, async (req, res, next) => { [req.user.userId] ); if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); - res.json(r.rows[0]); + const row = r.rows[0]; + row.level = Math.floor((row.total_ep || 0) / 500); + res.json(row); + } catch (err) { next(err); } +}); + +// POST /auth/progress — eine gelöste Karte verbuchen (EP + Streak + Detail pro Pair) +router.post('/progress', requireJwt, async (req, res, next) => { + try { + const userId = req.user.userId; + const { pair_id, correct, points } = req.body; + if (!pair_id) return res.status(400).json({ error: 'pair_id is required' }); + const pts = Math.max(0, parseInt(points) || 0); + const isCorrect = correct === true || correct === 'true'; + + // Detail pro Pair upserten + await query( + `INSERT INTO user_pair_progress (user_id, pair_id, seen_count, correct_count, wrong_count, earned_points) + VALUES ($1, $2, 1, $3, $4, $5) + ON CONFLICT (user_id, pair_id) DO UPDATE SET + seen_count = user_pair_progress.seen_count + 1, + correct_count = user_pair_progress.correct_count + $3, + wrong_count = user_pair_progress.wrong_count + $4, + earned_points = user_pair_progress.earned_points + $5`, + [userId, pair_id, isCorrect ? 1 : 0, isCorrect ? 0 : 1, pts] + ); + + // EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag + const upd = await query( + `UPDATE users_public SET + total_ep = total_ep + $2, + streak_days = CASE + WHEN last_practice_at IS NULL THEN 1 + WHEN last_practice_at::date = CURRENT_DATE THEN streak_days + WHEN last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN streak_days + 1 + ELSE 1 + END, + last_practice_at = NOW() + WHERE user_id = $1 + RETURNING total_ep, streak_days`, + [userId, pts] + ); + + if (!upd.rows.length) + return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' }); + + const { total_ep, streak_days } = upd.rows[0]; + res.json({ total_ep, streak_days, level: Math.floor(total_ep / 500) }); } catch (err) { next(err); } }); diff --git a/src/routes/claude.js b/src/routes/claude.js index 97efae9..7d270fb 100644 --- a/src/routes/claude.js +++ b/src/routes/claude.js @@ -68,4 +68,68 @@ router.post('/generate-pairs', async (req, res, next) => { } }); +// POST /api/claude/generate-words +// Body: { topic, count=15, difficulty? } +// Liefert eine Vorschau-Liste neuer Wörter (de/en/sv) — schreibt NICHTS in die DB. +router.post('/generate-words', async (req, res, next) => { + try { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) return res.status(500).json({ error: 'ANTHROPIC_API_KEY nicht konfiguriert' }); + + const topic = (req.body.topic || '').toString().trim(); + if (!topic) return res.status(400).json({ error: 'topic fehlt' }); + const count = Math.min(Math.max(parseInt(req.body.count) || 15, 1), 50); + const difficulty = req.body.difficulty ? ` Schwierigkeitsgrad: ${req.body.difficulty}.` : ''; + + const userPrompt = `Erstelle ${count} einzelne Vokabeln zum Thema/zur Kategorie: "${topic}".${difficulty}\n\n` + + `Es sollen lernbare Einzelwörter sein: Nomen, Verben oder Adjektive. ` + + `KEINE Pronomen, Artikel, Präpositionen oder Funktionswörter (kein der/die/das/ein/ich/wir/man/und/oder). ` + + `Keine Mehrwortausdrücke, keine Duplikate.\n\n` + + `Gib für jedes Wort die Übersetzung auf Deutsch, Englisch und Schwedisch an.\n\n` + + `Antworte NUR mit gültigem JSON ohne Markdown:\n` + + `{"words":[{"titel_de":"Apfel","titel_en":"apple","titel_sv":"äpple"}, ...]}`; + + const anthropicRes = 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: 'claude-haiku-4-5-20251001', + max_tokens: 4000, + system: 'Du bist ein Vokabel-Assistent. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown-Codeblöcke, ohne Erklärungen.', + messages: [{ role: 'user', content: userPrompt }], + }), + }); + + if (!anthropicRes.ok) { + const err = await anthropicRes.json().catch(() => ({})); + return res.status(anthropicRes.status).json({ error: err.error?.message || `Claude API Fehler ${anthropicRes.status}` }); + } + + const data = await anthropicRes.json(); + let rawText = data.content[0].text.trim(); + const mdMatch = rawText.match(/```(?:json)?\s*([\s\S]+?)\s*```/); + if (mdMatch) rawText = mdMatch[1]; + + const parsed = JSON.parse(rawText); + if (!Array.isArray(parsed.words)) return res.status(500).json({ error: 'Ungültiges JSON-Format von Claude' }); + + // Nur saubere Einträge mit mindestens einer Übersetzung zurückgeben + const words = parsed.words + .map(w => ({ + titel_de: (w.titel_de || '').toString().trim(), + titel_en: (w.titel_en || '').toString().trim(), + titel_sv: (w.titel_sv || '').toString().trim(), + })) + .filter(w => w.titel_de || w.titel_en || w.titel_sv); + + res.json({ words }); + } catch (err) { + next(err); + } +}); + module.exports = router; diff --git a/src/routes/feed.js b/src/routes/feed.js index 6c2ca6a..b82ce9d 100644 --- a/src/routes/feed.js +++ b/src/routes/feed.js @@ -42,11 +42,25 @@ router.get('/', requireJwt, async (req, res, next) => { const lang = ['de', 'en', 'sv'].includes(req.query.lang) ? req.query.lang : 'de'; const limit = Math.min(parseInt(req.query.limit) || 20, 100); - // 1. Random pairs + // 1. Random pairs — only fully ready content: + // pair published + linked question/statements published + a published picture exists. + // (Audio coverage is additionally enforced in Phase 2.) const pairsRes = await query( - `SELECT id, answer_type, status, difficulty_level, - question_id, positive_statement_id, negative_statement_id - FROM pairs + `SELECT p.id, p.answer_type, p.status, p.difficulty_level, + p.question_id, p.positive_statement_id, p.negative_statement_id + FROM pairs p + WHERE p.status = 'published' + AND (p.question_id IS NULL OR EXISTS ( + SELECT 1 FROM questions q WHERE q.id = p.question_id AND q.status = 'published')) + AND (p.positive_statement_id IS NULL OR EXISTS ( + SELECT 1 FROM statements s WHERE s.id = p.positive_statement_id AND s.status = 'published')) + AND (p.negative_statement_id IS NULL OR EXISTS ( + SELECT 1 FROM statements s WHERE s.id = p.negative_statement_id AND s.status = 'published')) + AND EXISTS ( + SELECT 1 FROM object_pairs op + JOIN object_pictures pic ON pic.object_id = op.object_id + JOIN pictures pp ON pp.id = pic.picture_id + WHERE op.pair_id = p.id AND pp.status = 'published') ORDER BY random() LIMIT $1`, [limit] @@ -140,6 +154,32 @@ router.get('/', requireJwt, async (req, res, next) => { negRes.rows.forEach(r => { statWordMap[r.statement_id]?.negative.push({ id: r.id, de: r.de, en: r.en, sv: r.sv }); }); } + // 7b. Fetch audios for the target language (question + statement sentences) + // keyed by `${source_table}|${source_id}|${source_field}` + const audioMap = {}; + { + const lookups = []; + questionIds.forEach(id => lookups.push(['questions', id, 'sentence'])); + statementIds.forEach(id => { + lookups.push(['statements', id, 'positive_sentence']); + lookups.push(['statements', id, 'negative_sentence']); + }); + if (lookups.length) { + const ids = [...new Set(lookups.map(l => l[1]))]; + const r = await query( + `SELECT source_table, source_id, source_field, audio_link, alignment + FROM audios + WHERE source_id = ANY($1) AND language = $2 AND status <> 'blocked'`, + [ids, lang] + ); + r.rows.forEach(a => { + audioMap[`${a.source_table}|${a.source_id}|${a.source_field}`] = + { url: a.audio_link, alignment: a.alignment }; + }); + } + } + const getAudio = (table, id, field) => audioMap[`${table}|${id}|${field}`] || null; + // 8. Fetch pictures via object_pairs → object_pictures → pictures (one per pair) const pictureMap = {}; // pairId → { url, blurhash } if (pairIds.length) { @@ -176,6 +216,8 @@ router.get('/', requireJwt, async (req, res, next) => { function buildStatement(id) { if (!id || !statementsMap[id]) return null; const s = statementsMap[id]; + const posAudio = getAudio('statements', id, 'positive_sentence'); + const negAudio = getAudio('statements', id, 'negative_sentence'); return { id, sentence_de: s.positive_sentence_de, @@ -187,17 +229,39 @@ router.get('/', requireJwt, async (req, res, next) => { answer: s.answer, positive_words: statWordMap[id]?.positive || [], negative_words: statWordMap[id]?.negative || [], + audio_url: posAudio?.url || null, + audio_alignment: posAudio?.alignment || null, + negative_audio_url: negAudio?.url || null, }; } function buildQuestion(id) { if (!id || !questionsMap[id]) return null; const q = questionsMap[id]; - return { id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv }; + const audio = getAudio('questions', id, 'sentence'); + return { + id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv, + audio_url: audio?.url || null, audio_alignment: audio?.alignment || null, + }; } - // 10. Assemble - const result = pairs.map(p => ({ + // Audio-Gate: jeder nicht-leere Satz, der in der Zielsprache angezeigt wird, + // muss ein Audio haben. Sonst fliegt das Pair aus dem Feed. + function hasRequiredAudio(p) { + const q = questionsMap[p.question_id]; + if (q && (q[`sentence_${lang}`] || '').trim() && !getAudio('questions', p.question_id, 'sentence')) + return false; + const ps = statementsMap[p.positive_statement_id]; + if (ps && (ps[`positive_sentence_${lang}`] || '').trim() && !getAudio('statements', p.positive_statement_id, 'positive_sentence')) + return false; + const ns = statementsMap[p.negative_statement_id]; + if (ns && (ns[`negative_sentence_${lang}`] || '').trim() && !getAudio('statements', p.negative_statement_id, 'negative_sentence')) + return false; + return true; + } + + // 10. Assemble (only pairs whose displayed sentences all have audio) + const result = pairs.filter(hasRequiredAudio).map(p => ({ id: p.id, answer_type: p.answer_type, status: p.status, diff --git a/src/routes/pairs.js b/src/routes/pairs.js index 0944959..8b84dfc 100644 --- a/src/routes/pairs.js +++ b/src/routes/pairs.js @@ -77,6 +77,16 @@ router.patch('/:id', async (req, res, next) => { if (atErr) return res.status(400).json({ error: atErr }); } + // Publish-Gating: ein Pair muss erst 'reviewed' sein, bevor es published werden darf. + if (req.body.status === 'published') { + const cur = await query('SELECT status FROM pairs WHERE id = $1', [req.params.id]); + if (!cur.rows.length) return res.status(404).json({ error: 'Not found' }); + if (cur.rows[0].status === 'draft') + return res.status(409).json({ + error: 'Pair muss erst geprüft werden (Status "reviewed"), bevor es veröffentlicht werden kann.', + }); + } + const tsField = STATUS_TIMESTAMP[req.body.status]; if (tsField && !req.body[tsField]) { fields.push(tsField); diff --git a/src/routes/words.js b/src/routes/words.js index 0d8c490..9c93dcd 100644 --- a/src/routes/words.js +++ b/src/routes/words.js @@ -45,11 +45,13 @@ router.get('/', async (req, res, next) => { // POST /api/words router.post('/', async (req, res, next) => { try { - const { titel_de, titel_en, titel_sv, difficulty_level } = req.body; + 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(', ')}` }); const result = await query( - `INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, requested_at) - VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, - [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null] + `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] ); res.status(201).json({ ...result.rows[0], picture_ids: [], category_ids: [] }); } catch (err) { next(err); } diff --git a/src/voices.js b/src/voices.js new file mode 100644 index 0000000..3a0440d --- /dev/null +++ b/src/voices.js @@ -0,0 +1,17 @@ +// Default ElevenLabs voice per language (ISO 639-1 → voice_id). +// Configure via env (ELEVENLABS_VOICE_DE/EN/SV). Falls back to a shared multilingual voice. + +const FALLBACK_VOICE = process.env.ELEVENLABS_VOICE_DEFAULT || 'XXCqsM8I9KhqA7jLGj1U'; + +const VOICES = { + de: process.env.ELEVENLABS_VOICE_DE || FALLBACK_VOICE, + en: process.env.ELEVENLABS_VOICE_EN || FALLBACK_VOICE, + sv: process.env.ELEVENLABS_VOICE_SV || FALLBACK_VOICE, +}; + +/** Returns the configured voice_id for a language code (default: fallback voice). */ +function voiceForLanguage(lang) { + return VOICES[lang] || FALLBACK_VOICE; +} + +module.exports = { VOICES, voiceForLanguage, FALLBACK_VOICE };