pediatric-ai-scribe-v3/src/routes/learningHub.js
Daniel Onyejesi e3da7c5bb0 Render presentations as slides in Learning Hub viewer
- New GET /api/learning/content/:slug/slides (authMiddleware only)
  Renders Marp markdown via marp-core, returns {css, slides[]} for
  any authenticated user — no moderator access required

- showViewer: when content_type === 'presentation', hides the body card
  and shows a presentation card with title, slide count, and View Slides button
  Auto-opens the slide modal immediately on load; button allows re-opening

- openSlidesFromSlug(): fetches slides from user-accessible endpoint
  then opens the existing slide preview modal (arrow keys, swipe, dots)

- Viewer already correctly hides feed/categories/search when viewing content;
  "Back to Feed" button returns to the main list
2026-03-24 03:04:14 -04:00

254 lines
10 KiB
JavaScript

// ============================================================
// LEARNING HUB ROUTES — User-facing content & quizzes
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
// ============================================================
// GET CATEGORIES
// ============================================================
router.get('/categories', async function(req, res) {
try {
var categories = await db.all(
'SELECT id, name, slug, description FROM learning_categories ORDER BY sort_order ASC, name ASC',
[]
);
res.json({ success: true, categories: categories });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET LATEST CONTENT (feed)
// ============================================================
router.get('/feed', async function(req, res) {
try {
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
var offset = parseInt(req.query.offset) || 0;
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
cat.name as category_name, cat.slug as category_slug,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
WHERE c.published = true
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`,
[limit, offset]
);
var total = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
res.json({ success: true, content: content, total: total ? parseInt(total.count) : 0 });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET CONTENT BY CATEGORY
// ============================================================
router.get('/category/:slug', async function(req, res) {
try {
var cat = await db.get('SELECT * FROM learning_categories WHERE slug = ?', [req.params.slug]);
if (!cat) return res.status(404).json({ error: 'Category not found' });
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN users u ON c.author_id = u.id
WHERE c.category_id = ? AND c.published = true
ORDER BY c.created_at DESC`,
[cat.id]
);
res.json({ success: true, category: cat, content: content });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET SINGLE CONTENT (with questions)
// ============================================================
router.get('/content/:slug', async function(req, res) {
try {
var item = await db.get(
`SELECT c.*, cat.name as category_name, cat.slug as category_slug, u.name as author_name
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
WHERE c.slug = ? AND c.published = true`,
[req.params.slug]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
// Get questions + options
var questions = await db.all(
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[item.id]
);
for (var i = 0; i < questions.length; i++) {
var options = await db.all(
'SELECT id, option_text, sort_order FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[questions[i].id]
);
questions[i].options = options;
}
// Get user's past attempts
var progress = await db.all(
'SELECT score, total, completed_at FROM learning_progress WHERE user_id = ? AND content_id = ? ORDER BY completed_at DESC LIMIT 5',
[req.user.id, item.id]
);
item.questions = questions;
item.progress = progress;
res.json({ success: true, content: item });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// RENDER PRESENTATION SLIDES (user-accessible)
// ============================================================
router.get('/content/:slug/slides', async function(req, res) {
try {
var item = await db.get(
'SELECT body, content_type FROM learning_content WHERE slug = ? AND published = true',
[req.params.slug]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
if (item.content_type !== 'presentation') return res.status(400).json({ error: 'Not a presentation' });
var { Marp } = require('@marp-team/marp-core');
var marp = new Marp({ html: false });
var { html, css } = marp.render(item.body || '');
var slides = [];
var sectionReg = /<section[^>]*>[\s\S]*?<\/section>/g;
var match;
while ((match = sectionReg.exec(html)) !== null) slides.push(match[0]);
if (slides.length === 0) slides.push(html);
res.json({ success: true, css: css, slides: slides });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// SUBMIT QUIZ ANSWERS
// ============================================================
router.post('/submit-quiz', async function(req, res) {
try {
var { contentId, answers } = req.body;
// answers = [ { questionId: 1, optionId: 5 }, ... ]
if (!contentId || !answers || !Array.isArray(answers)) {
return res.status(400).json({ error: 'contentId and answers array required' });
}
// Get all questions for this content (include question_type)
var questions = await db.all(
'SELECT id, question_text, question_type, explanation as general_explanation FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[contentId]
);
var results = [];
var score = 0;
for (var i = 0; i < questions.length; i++) {
var q = questions[i];
var userAnswer = answers.find(function(a) { return a.questionId === q.id; });
// Get all options with correct flag
var options = await db.all(
'SELECT id, option_text, is_correct, explanation FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[q.id]
);
var isCorrect, resultEntry;
if (q.question_type === 'multi') {
var selectedIds = (userAnswer && Array.isArray(userAnswer.optionIds)) ? userAnswer.optionIds.map(Number) : [];
var correctIds = options.filter(function(o) { return o.is_correct; }).map(function(o) { return o.id; });
var allCorrectChosen = correctIds.length > 0 && correctIds.every(function(id) { return selectedIds.indexOf(id) !== -1; });
var noWrongChosen = selectedIds.every(function(id) { return correctIds.indexOf(id) !== -1; });
isCorrect = allCorrectChosen && noWrongChosen && selectedIds.length > 0;
if (isCorrect) score++;
resultEntry = {
questionId: q.id,
questionType: 'multi',
questionText: q.question_text,
selectedOptionIds: selectedIds,
correctOptionIds: correctIds,
isCorrect: isCorrect,
generalExplanation: q.general_explanation || ''
};
} else {
var selectedOptionId = userAnswer ? userAnswer.optionId : null;
var correctOption = options.find(function(o) { return o.is_correct; });
var selectedOption = selectedOptionId ? options.find(function(o) { return o.id === selectedOptionId; }) : null;
isCorrect = selectedOption ? selectedOption.is_correct : false;
if (isCorrect) score++;
resultEntry = {
questionId: q.id,
questionType: q.question_type,
questionText: q.question_text,
selectedOptionId: selectedOptionId,
isCorrect: isCorrect,
correctOptionId: correctOption ? correctOption.id : null,
correctOptionText: correctOption ? correctOption.option_text : '',
selectedExplanation: selectedOption && !isCorrect ? selectedOption.explanation : '',
generalExplanation: q.general_explanation || ''
};
}
results.push(resultEntry);
}
// Save progress
await db.run(
'INSERT INTO learning_progress (user_id, content_id, score, total) VALUES (?, ?, ?, ?)',
[req.user.id, contentId, score, questions.length]
);
res.json({
success: true,
score: score,
total: questions.length,
percentage: questions.length > 0 ? Math.round((score / questions.length) * 100) : 0,
results: results
});
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// SEARCH CONTENT
// ============================================================
router.get('/search', async function(req, res) {
try {
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [] });
var pattern = '%' + q + '%';
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
ORDER BY c.created_at DESC LIMIT 30`,
[pattern, pattern, pattern]
);
res.json({ success: true, content: content });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
module.exports = router;