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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`);
|
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)
|
// 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(`
|
await query(`
|
||||||
INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, status, published_at)
|
INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, status, published_at)
|
||||||
VALUES
|
VALUES
|
||||||
('en', 'Englisch', 'English', 'Engelska', 'published', NOW()),
|
('en', 'Englisch', 'English', 'Engelska', 'published', NOW()),
|
||||||
('sv', 'Schwedisch', 'Swedish', 'Svenska', '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(() => {});
|
`).catch(() => {});
|
||||||
|
|
||||||
console.log('Migration complete');
|
console.log('Migration complete');
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ app.get('/health', async (req, res) => {
|
|||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
app.use('/auth', require('./routes/auth'));
|
app.use('/auth', require('./routes/auth'));
|
||||||
|
app.use('/auth/feed', require('./routes/feed').router);
|
||||||
|
|
||||||
// Routes — protected by Bearer token
|
// Routes — protected by Bearer token
|
||||||
app.use('/api', auth, require('./routes/index'));
|
app.use('/api', auth, require('./routes/index'));
|
||||||
|
|||||||
228
src/routes/feed.js
Normal file
228
src/routes/feed.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user