- 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>
282 lines
12 KiB
JavaScript
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 };
|