From 6d13000248cc246512f1c7db9557da55410105fb Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 25 May 2026 18:37:06 +0200 Subject: [PATCH] feat: add /auth/feed endpoint for hydrated learning pairs - GET /auth/feed?lang=sv&limit=20 (JWT, end-user allowed) - Resolves {{uuid}} placeholders to word labels in all languages - Includes picture URLs, pos/neg words per statement - Fix migration seed: use full unique index (non-partial) for ON CONFLICT Co-Authored-By: Claude Sonnet 4.6 --- src/db-migrate.js | 5 +- src/index.js | 1 + src/routes/feed.js | 228 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/routes/feed.js diff --git a/src/db-migrate.js b/src/db-migrate.js index bd0661b..5e9d207 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -469,13 +469,14 @@ async function migrate() { await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_public_user_id_idx ON users_public (user_id) WHERE user_id IS NOT NULL`); // Seed languages (de exists, add en + sv) - await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en) WHERE short_en IS NOT NULL`).catch(() => {}); + // 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(` INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, status, published_at) VALUES ('en', 'Englisch', 'English', 'Engelska', 'published', NOW()), ('sv', 'Schwedisch', 'Swedish', 'Svenska', 'published', NOW()) - ON CONFLICT (short_en) DO NOTHING + ON CONFLICT (short_en) DO UPDATE SET status = EXCLUDED.status, published_at = COALESCE(languages.published_at, EXCLUDED.published_at) `).catch(() => {}); console.log('Migration complete'); diff --git a/src/index.js b/src/index.js index 73afeb6..0a75a36 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,7 @@ app.get('/health', async (req, res) => { // Public routes app.use('/auth', require('./routes/auth')); +app.use('/auth/feed', require('./routes/feed').router); // Routes — protected by Bearer token app.use('/api', auth, require('./routes/index')); diff --git a/src/routes/feed.js b/src/routes/feed.js new file mode 100644 index 0000000..d3ab3c2 --- /dev/null +++ b/src/routes/feed.js @@ -0,0 +1,228 @@ +// GET /auth/feed?lang=sv&limit=20 +// Returns hydrated pairs for the learning feed (end-user JWT allowed). +// {{uuid}} placeholders in sentences are resolved to word/object labels in all languages. + +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' }); + } +} + +// Extract all {{uuid}} placeholder values from a string +function extractUuids(text) { + if (!text) return []; + const matches = text.matchAll(/\{\{([0-9a-f-]{36})\}\}/g); + return [...matches].map(m => m[1]); +} + +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 + const pairsRes = await query( + `SELECT id, answer_type, status, difficulty_level, + question_id, positive_statement_id, negative_statement_id + FROM pairs + ORDER BY random() + LIMIT $1`, + [limit] + ); + if (!pairsRes.rows.length) return res.json([]); + + const pairs = pairsRes.rows; + + // 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. Collect all {{uuid}} references from all sentences + const allUuids = new Set(); + 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, + ]), + ]; + allSentences.forEach(s => extractUuids(s).forEach(u => allUuids.add(u))); + + const uuidArr = [...allUuids]; + + // 6. Resolve UUIDs → words (direct) or via objects→words + const placeholderMap = {}; // uuid → { de, en, sv } + if (uuidArr.length) { + // Direct word lookup + const wordRes = await query( + `SELECT id, titel_de AS de, titel_en AS en, titel_sv AS sv FROM words WHERE id = ANY($1)`, + [uuidArr] + ); + wordRes.rows.forEach(w => { placeholderMap[w.id] = { de: w.de, en: w.en, sv: w.sv }; }); + + // Object lookup → get first linked word as label + const objUuids = uuidArr.filter(u => !placeholderMap[u]); + if (objUuids.length) { + const objRes = await query( + `SELECT ow.object_id AS id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv + FROM object_words ow + JOIN words w ON w.id = ow.word_id + WHERE ow.object_id = ANY($1)`, + [objUuids] + ); + // First word per object + const seen = new Set(); + objRes.rows.forEach(r => { + if (!seen.has(r.id)) { placeholderMap[r.id] = { de: r.de, en: r.en, sv: r.sv }; seen.add(r.id); } + }); + } + } + + // 7. Fetch statement→word links (positive + negative) + const statWordMap = {}; // statementId → { positive: [], negative: [] } + 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 }); }); + } + + // 8. Find pictures: via objects referenced in sentences, or via object_pairs + const objectUuids = uuidArr.filter(u => !Object.values(questionsMap).concat(Object.values(statementsMap)) + .flatMap(x => [x.sentence_de || '', x.positive_sentence_de || '']) + .some(s => s.includes(u) && placeholderMap[u]) + ? false : placeholderMap[u] !== undefined + ); + + // Simpler: collect all object-UUIDs (those resolved via object_words) + const resolvedObjectIds = new Set(); + if (uuidArr.length) { + const objCheckRes = await query( + `SELECT DISTINCT object_id AS id FROM object_words WHERE object_id = ANY($1)`, + [uuidArr] + ); + objCheckRes.rows.forEach(r => resolvedObjectIds.add(r.id)); + } + + const pictureMap = {}; // objectId → { url, blurhash } + if (resolvedObjectIds.size) { + const picRes = await query( + `SELECT op.object_id, p.picture_link AS url, p.blurhash + FROM object_pictures op + JOIN pictures p ON p.id = op.picture_id + WHERE op.object_id = ANY($1) + LIMIT 1`, + [[...resolvedObjectIds]] + ); + picRes.rows.forEach(r => { pictureMap[r.object_id] = { url: r.url, blurhash: r.blurhash }; }); + } + + // Pick first available picture across all placeholders in the pair + function pickPicture(pairUuids) { + for (const u of pairUuids) { + if (pictureMap[u]) return pictureMap[u]; + } + return null; + } + + function buildStatement(id) { + if (!id || !statementsMap[id]) return null; + const s = statementsMap[id]; + 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 || [], + }; + } + + 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 }; + } + + // 9. Assemble + const result = pairs.map(p => { + const allText = [ + questionsMap[p.question_id]?.sentence_de, + questionsMap[p.question_id]?.sentence_sv, + statementsMap[p.positive_statement_id]?.positive_sentence_de, + statementsMap[p.positive_statement_id]?.positive_sentence_sv, + ].join(' '); + const pairUuids = extractUuids(allText); + + return { + 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: pickPicture(pairUuids), + placeholders: Object.fromEntries(pairUuids.map(u => [u, placeholderMap[u] || null])), + }; + }); + + res.json(result); + } catch (err) { next(err); } +}); + +module.exports = { router, requireJwt };