feat: Status-Pipeline (reviewed), Audio-Verknüpfung+Coverage, EP-Fortschritt, Wort-Generierung

- reviewed-Status für objects/questions/statements/pairs (Constraints)
- feed: nur fertige Inhalte (published + Bild + Audio-Gate), audio_url
- pairs: Publish-Gating (draft→published = 409)
- audios: source_table/source_id/source_field/language + Unique-Index;
  generate-for, generate-batch, GET /coverage; voices.js (Voice je Sprache)
- auth: POST /auth/progress, /auth/me mit total_ep/streak/level;
  users_public EP-Spalten + user_pair_progress.earned_points
- claude: POST /generate-words; words POST akzeptiert status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:29:48 +02:00
parent 75f05f45f2
commit 9bfd5e8dba
9 changed files with 457 additions and 74 deletions

View File

@@ -14,3 +14,8 @@ ANTHROPIC_API_KEY=sk-ant-...
# ElevenLabs (für TTS-Generierung) # ElevenLabs (für TTS-Generierung)
ELEVENLABS_API_KEY=sk_... 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=

View File

@@ -132,7 +132,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS questions ( CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft' status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')), CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
sentence_de TEXT, sentence_de TEXT,
sentence_en TEXT, sentence_en TEXT,
sentence_sv TEXT, sentence_sv TEXT,
@@ -152,7 +152,7 @@ async function migrate() {
for (const col of questionCols) for (const col of questionCols)
await query(`ALTER TABLE questions ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); 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 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(` await query(`
DROP TRIGGER IF EXISTS questions_updated_at ON questions; DROP TRIGGER IF EXISTS questions_updated_at ON questions;
@@ -165,7 +165,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS statements ( CREATE TABLE IF NOT EXISTS statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft' 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_de TEXT,
negative_sentence_en TEXT, negative_sentence_en TEXT,
negative_sentence_sv TEXT, negative_sentence_sv TEXT,
@@ -189,7 +189,7 @@ async function migrate() {
for (const col of stmtCols) for (const col of stmtCols)
await query(`ALTER TABLE statements ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {}); 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 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(` await query(`
DROP TRIGGER IF EXISTS statements_updated_at ON statements; DROP TRIGGER IF EXISTS statements_updated_at ON statements;
@@ -221,7 +221,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS pairs ( CREATE TABLE IF NOT EXISTS pairs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft' 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), difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50),
answer_type VARCHAR(20) NOT NULL answer_type VARCHAR(20) NOT NULL
CHECK (answer_type IN ('yes_no', 'text', 'word')), 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 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 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 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 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 DROP CONSTRAINT IF EXISTS pairs_difficulty_level_check`).catch(() => {});
@@ -274,7 +274,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS objects ( CREATE TABLE IF NOT EXISTS objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft' status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')), CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
selections JSONB, selections JSONB,
notes TEXT, notes TEXT,
blocked_topic 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(` await query(`
DROP TRIGGER IF EXISTS objects_updated_at ON objects; DROP TRIGGER IF EXISTS objects_updated_at ON objects;
CREATE TRIGGER objects_updated_at 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(`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`); 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) // Seed languages (de exists, add en + sv)
// Full unique constraint (not partial) so ON CONFLICT works cleanly // 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(() => {}); 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(` await query(`
CREATE OR REPLACE FUNCTION update_last_seen_at() CREATE OR REPLACE FUNCTION update_last_seen_at()
RETURNS TRIGGER AS $$ 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(` await query(`
DROP TRIGGER IF EXISTS audios_updated_at ON audios; DROP TRIGGER IF EXISTS audios_updated_at ON audios;
CREATE TRIGGER audios_updated_at CREATE TRIGGER audios_updated_at

View File

@@ -2,18 +2,129 @@ const router = require('express').Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { query } = require('../db'); const { query } = require('../db');
const { uploadFile, deleteFile, keyFromUrl } = require('../s3'); const { uploadFile, deleteFile, keyFromUrl } = require('../s3');
const { voiceForLanguage } = require('../voices');
const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1'; const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1';
const ALLOWED_STATUSES = ['generated', 'published', 'blocked']; 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 // GET /api/audios
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { 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 params = [Math.min(parseInt(limit), 500), parseInt(offset)];
const conditions = []; const conditions = [];
if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); } 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 (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 where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query( const result = await query(
`SELECT * FROM audios ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, `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); } } 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 // GET /api/audios/:id
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
@@ -32,72 +168,85 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err); } } 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) => { router.post('/generate', async (req, res, next) => {
try { try {
const { const { text, voice_id, language, source_table, source_id, source_field,
text, model_id, speed, stability, similarity_boost, style } = req.body;
voice_id,
model_id = 'eleven_multilingual_v2',
speed = 1.0,
stability = 0.5,
similarity_boost = 0.75,
style = 0.0,
} = req.body;
if (!text) return res.status(400).json({ error: 'text 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' }); if (!voice_id && !language)
return res.status(400).json({ error: 'voice_id or language is required' });
const apiKey = process.env.ELEVENLABS_API_KEY; const row = await generateAndStore({
if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' }); text, voice_id, language, model_id, speed, stability, similarity_boost, style,
source_table, source_id, source_field,
const elevenRes = await fetch( });
`${ELEVENLABS_BASE}/text-to-speech/${voice_id}/with-timestamps`, res.status(201).json(row);
{ } catch (err) {
method: 'POST', if (err.status) return res.status(err.status).json({ error: err.message, detail: err.detail });
headers: { next(err);
'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 },
}),
} }
); });
if (!elevenRes.ok) { // POST /api/audios/generate-for — Text aus der Quell-Zeile auflösen, dann generieren
const err = await elevenRes.text(); router.post('/generate-for', async (req, res, next) => {
return res.status(elevenRes.status).json({ error: 'ElevenLabs error', detail: err }); 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 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` });
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 results = { generated: 0, failed: 0, errors: [] };
const buffer = Buffer.from(audio_base64, 'base64'); for (const u of todo) {
try {
const id = uuidv4(); await generateAndStore({
const key = `audios/${id}/${uuidv4()}.mp3`; text: u.text, language: u.language,
const audio_link = await uploadFile(key, buffer, 'audio/mpeg'); source_table: u.source_table, source_id: u.source_id, source_field: u.source_field,
});
const result = await query( results.generated++;
`INSERT INTO audios } catch (err) {
(id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style) results.failed++;
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) results.errors.push({ source_id: u.source_id, field: u.source_field, lang: u.language, error: err.message });
RETURNING *`, }
[id, text, audio_link, JSON.stringify(alignment), voice_id, model_id, }
speed, stability, similarity_boost, style] res.json(results);
);
res.status(201).json(result.rows[0]);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// PATCH /api/audios/:id — Status ändern // PATCH /api/audios/:id — Status / Metadaten ändern
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const allowed = ['status', 'text', 'audio_link', 'alignment', 'voice_id', 'model_id', 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)); 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 (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });

View File

@@ -136,6 +136,9 @@ router.get('/me', requireJwt, async (req, res, next) => {
const r = await query( const r = await query(
`SELECT u.id, u.email, u.role, `SELECT u.id, u.email, u.role,
un.username, 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, 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 lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel
FROM users u FROM users u
@@ -147,7 +150,54 @@ router.get('/me', requireJwt, async (req, res, next) => {
[req.user.userId] [req.user.userId]
); );
if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); 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); } } catch (err) { next(err); }
}); });

View File

@@ -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; module.exports = router;

View File

@@ -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 lang = ['de', 'en', 'sv'].includes(req.query.lang) ? req.query.lang : 'de';
const limit = Math.min(parseInt(req.query.limit) || 20, 100); 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( const pairsRes = await query(
`SELECT id, answer_type, status, difficulty_level, `SELECT p.id, p.answer_type, p.status, p.difficulty_level,
question_id, positive_statement_id, negative_statement_id p.question_id, p.positive_statement_id, p.negative_statement_id
FROM pairs 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() ORDER BY random()
LIMIT $1`, LIMIT $1`,
[limit] [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 }); }); 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) // 8. Fetch pictures via object_pairs → object_pictures → pictures (one per pair)
const pictureMap = {}; // pairId → { url, blurhash } const pictureMap = {}; // pairId → { url, blurhash }
if (pairIds.length) { if (pairIds.length) {
@@ -176,6 +216,8 @@ router.get('/', requireJwt, async (req, res, next) => {
function buildStatement(id) { function buildStatement(id) {
if (!id || !statementsMap[id]) return null; if (!id || !statementsMap[id]) return null;
const s = statementsMap[id]; const s = statementsMap[id];
const posAudio = getAudio('statements', id, 'positive_sentence');
const negAudio = getAudio('statements', id, 'negative_sentence');
return { return {
id, id,
sentence_de: s.positive_sentence_de, sentence_de: s.positive_sentence_de,
@@ -187,17 +229,39 @@ router.get('/', requireJwt, async (req, res, next) => {
answer: s.answer, answer: s.answer,
positive_words: statWordMap[id]?.positive || [], positive_words: statWordMap[id]?.positive || [],
negative_words: statWordMap[id]?.negative || [], negative_words: statWordMap[id]?.negative || [],
audio_url: posAudio?.url || null,
audio_alignment: posAudio?.alignment || null,
negative_audio_url: negAudio?.url || null,
}; };
} }
function buildQuestion(id) { function buildQuestion(id) {
if (!id || !questionsMap[id]) return null; if (!id || !questionsMap[id]) return null;
const q = questionsMap[id]; 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 // Audio-Gate: jeder nicht-leere Satz, der in der Zielsprache angezeigt wird,
const result = pairs.map(p => ({ // 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, id: p.id,
answer_type: p.answer_type, answer_type: p.answer_type,
status: p.status, status: p.status,

View File

@@ -77,6 +77,16 @@ router.patch('/:id', async (req, res, next) => {
if (atErr) return res.status(400).json({ error: atErr }); 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]; const tsField = STATUS_TIMESTAMP[req.body.status];
if (tsField && !req.body[tsField]) { if (tsField && !req.body[tsField]) {
fields.push(tsField); fields.push(tsField);

View File

@@ -45,11 +45,13 @@ router.get('/', async (req, res, next) => {
// POST /api/words // POST /api/words
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { 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( const result = await query(
`INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, requested_at) `INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, status, requested_at)
VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, VALUES ($1, $2, $3, $4, COALESCE($5, 'requested'), NOW()) RETURNING *`,
[titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null] [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: [] }); res.status(201).json({ ...result.rows[0], picture_ids: [], category_ids: [] });
} catch (err) { next(err); } } catch (err) { next(err); }

17
src/voices.js Normal file
View File

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