Files
snakkimo-API/src/routes/feed.js
admin 9bfd5e8dba 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>
2026-06-02 21:29:48 +02:00

282 lines
12 KiB
JavaScript

// GET /auth/feed?lang=sv&limit=20
// Returns hydrated pairs for the learning feed.
// Placeholders in sentences: {{label.w:wordId}} or {{label.o:objectId}}
// Pictures come from object_pairs → object_pictures → pictures.
const router = require('express').Router();
const jwt = require('jsonwebtoken');
const { query } = require('../db');
function requireJwt(req, res, next) {
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Parse {{label.w:uuid}} or {{label.o:uuid}} from a sentence string
function extractPlaceholders(text) {
if (!text) return [];
return [...text.matchAll(/\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}/g)]
.map(m => ({ label: m[1], type: m[2] === 'w' ? 'word' : 'object', id: m[3] }));
}
// Collect unique IDs of a given type from multiple placeholder arrays
function collectIds(lists, filterType) {
const ids = new Set();
for (const list of lists) {
for (const p of list) {
if (!filterType || p.type === filterType) ids.add(p.id);
}
}
return [...ids];
}
router.get('/', requireJwt, async (req, res, next) => {
try {
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 — 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 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]
);
if (!pairsRes.rows.length) return res.json([]);
const pairs = pairsRes.rows;
const pairIds = pairs.map(p => p.id);
// 2. Collect IDs
const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))];
const statementIds = [...new Set([
...pairs.map(p => p.positive_statement_id),
...pairs.map(p => p.negative_statement_id),
].filter(Boolean))];
// 3. Fetch questions
const questionsMap = {};
if (questionIds.length) {
const r = await query(
`SELECT id, sentence_de, sentence_en, sentence_sv FROM questions WHERE id = ANY($1)`,
[questionIds]
);
r.rows.forEach(q => { questionsMap[q.id] = q; });
}
// 4. Fetch statements
const statementsMap = {};
if (statementIds.length) {
const r = await query(
`SELECT id, positive_sentence_de, positive_sentence_en, positive_sentence_sv,
negative_sentence_de, negative_sentence_en, negative_sentence_sv, answer
FROM statements WHERE id = ANY($1)`,
[statementIds]
);
r.rows.forEach(s => { statementsMap[s.id] = s; });
}
// 5. Parse all placeholders → find object IDs that need selections
const allSentences = [
...Object.values(questionsMap).flatMap(q => [q.sentence_de, q.sentence_en, q.sentence_sv]),
...Object.values(statementsMap).flatMap(s => [
s.positive_sentence_de, s.positive_sentence_en, s.positive_sentence_sv,
s.negative_sentence_de, s.negative_sentence_en, s.negative_sentence_sv,
]),
];
const allPlaceholderLists = allSentences.map(s => extractPlaceholders(s));
const objectIds = collectIds(allPlaceholderLists, 'object');
// 6. Fetch selections for object placeholders
const selectionsMap = {}; // objectId → selections[]
if (objectIds.length) {
const r = await query(
`SELECT id, selections FROM objects WHERE id = ANY($1)`,
[objectIds]
);
r.rows.forEach(o => { selectionsMap[o.id] = o.selections || []; });
}
// Build global placeholder map: id → { type, selections? }
// Labels are embedded in the sentence text — no lookup needed.
const placeholderMap = {};
for (const list of allPlaceholderLists) {
for (const p of list) {
if (!placeholderMap[p.id]) {
placeholderMap[p.id] = p.type === 'object'
? { type: 'object', selections: selectionsMap[p.id] || [] }
: { type: 'word' };
}
}
}
// 7. Fetch positive + negative words for PairWordCard answer options
const statWordMap = {};
if (statementIds.length) {
const posRes = await query(
`SELECT spw.statement_id, w.id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv
FROM statement_positive_words spw
JOIN words w ON w.id = spw.word_id
WHERE spw.statement_id = ANY($1)`,
[statementIds]
);
const negRes = await query(
`SELECT snw.statement_id, w.id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv
FROM statement_negative_words snw
JOIN words w ON w.id = snw.word_id
WHERE snw.statement_id = ANY($1)`,
[statementIds]
);
statementIds.forEach(id => { statWordMap[id] = { positive: [], negative: [] }; });
posRes.rows.forEach(r => { statWordMap[r.statement_id]?.positive.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)
const pictureMap = {}; // pairId → { url, blurhash }
if (pairIds.length) {
const picRes = await query(
`SELECT DISTINCT ON (op.pair_id)
op.pair_id, p.picture_link AS url, p.blurhash
FROM object_pairs op
JOIN object_pictures pic ON pic.object_id = op.object_id
JOIN pictures p ON p.id = pic.picture_id
WHERE op.pair_id = ANY($1)
ORDER BY op.pair_id, p.created_at`,
[pairIds]
);
picRes.rows.forEach(r => { pictureMap[r.pair_id] = { url: r.url, blurhash: r.blurhash }; });
}
// 9. Build per-pair placeholder subset
function getPairPlaceholders(p) {
const sentences = [
questionsMap[p.question_id]?.sentence_de,
questionsMap[p.question_id]?.sentence_en,
questionsMap[p.question_id]?.sentence_sv,
statementsMap[p.positive_statement_id]?.positive_sentence_de,
statementsMap[p.positive_statement_id]?.positive_sentence_en,
statementsMap[p.positive_statement_id]?.positive_sentence_sv,
statementsMap[p.negative_statement_id]?.negative_sentence_de,
statementsMap[p.negative_statement_id]?.negative_sentence_en,
statementsMap[p.negative_statement_id]?.negative_sentence_sv,
];
const ids = new Set(sentences.flatMap(s => extractPlaceholders(s).map(ph => ph.id)));
return Object.fromEntries([...ids].map(id => [id, placeholderMap[id] || null]));
}
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,
sentence_en: s.positive_sentence_en,
sentence_sv: s.positive_sentence_sv,
negative_sentence_de: s.negative_sentence_de,
negative_sentence_en: s.negative_sentence_en,
negative_sentence_sv: s.negative_sentence_sv,
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];
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,
};
}
// 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,
difficulty_level: p.difficulty_level,
lang,
question: buildQuestion(p.question_id),
positive_statement: buildStatement(p.positive_statement_id),
negative_statement: buildStatement(p.negative_statement_id),
picture: pictureMap[p.id] || null,
placeholders: getPairPlaceholders(p),
}));
res.json(result);
} catch (err) { next(err); }
});
module.exports = { router, requireJwt };