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