v3.1: Add Learning Hub with CMS, quizzes, and 3-role user system

- New Learning Hub under Pediatric menu with content feed, category browsing,
  search, content viewer, and interactive quizzes (MCQ, true/false)
- Quiz system with per-option wrong-answer explanations and general explanations
- Admin CMS for creating/editing categories, content (articles, pearls, quizzes),
  and inline question builder with answer options
- 3-role system: admin (full access), moderator (can manage Learning Hub content),
  user (all clinical features + learning hub read access)
- 5 new database tables: learning_categories, learning_content, learning_questions,
  learning_options, learning_progress
- User progress tracking with score history per quiz
- Moderators see "Content Manager" tab instead of full Admin panel
This commit is contained in:
Daniel Onyejesi 2026-03-23 19:32:47 -04:00
parent fc3c58e4eb
commit 4af622913f
13 changed files with 1616 additions and 12 deletions

View file

@ -1,6 +1,6 @@
services:
pediatric-scribe:
image: danielonyejesi/pediatric-ai-scribe-v3:v3.0
image: danielonyejesi/pediatric-ai-scribe-v3:v3.1
ports:
- "3552:3000"
env_file:

View file

@ -1,6 +1,6 @@
{
"name": "pediatric-ai-scribe",
"version": "3.0.0",
"version": "3.1.0",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {

View file

@ -494,3 +494,74 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.dx-result-item:last-child{border-bottom:none;}
.dx-result-item:hover{background:var(--g50);}
.dx-result-code{font-weight:700;color:var(--blue);margin-right:4px;}
/* ============================================================
LEARNING HUB
============================================================ */
/* Category bar */
.lh-category-bar{display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;}
.lh-cat-pill{padding:5px 14px;border-radius:20px;border:1px solid var(--g300);background:white;font-size:13px;cursor:pointer;transition:all 0.15s;font-family:inherit;color:var(--g600);}
.lh-cat-pill:hover{border-color:var(--blue);color:var(--blue);}
.lh-cat-pill.active{background:var(--blue);color:white;border-color:var(--blue);}
/* Feed */
.lh-feed{display:flex;flex-direction:column;gap:8px;}
.lh-feed-item{padding:14px 16px;cursor:pointer;transition:box-shadow 0.15s,border-color 0.15s;}
.lh-feed-item:hover{box-shadow:var(--shadow-md);border-color:var(--blue-light);}
.lh-feed-item-header{display:flex;align-items:flex-start;gap:12px;}
.lh-feed-title{font-size:14px;font-weight:600;color:var(--g800);}
.lh-feed-meta{font-size:12px;color:var(--g500);display:flex;gap:8px;flex-wrap:wrap;margin-top:2px;}
.lh-feed-meta span:not(:last-child)::after{content:'·';margin-left:8px;color:var(--g300);}
.lh-feed-badges{display:flex;gap:6px;flex-shrink:0;align-items:flex-start;}
.lh-badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);white-space:nowrap;}
.lh-badge-quiz{background:var(--amber-light);color:#92400e;}
/* Viewer */
.lh-viewer .card{margin-bottom:0;}
.lh-content-body{font-size:14px;line-height:1.75;color:var(--g700);}
.lh-content-body h1,.lh-content-body h2,.lh-content-body h3{margin:16px 0 8px;color:var(--g800);}
.lh-content-body p{margin-bottom:12px;}
.lh-content-body ul,.lh-content-body ol{margin:0 0 12px 20px;}
.lh-content-body li{margin-bottom:4px;}
.lh-content-body strong{color:var(--g900);}
.lh-content-body code{background:var(--g100);padding:2px 6px;border-radius:4px;font-size:13px;}
.lh-content-body blockquote{border-left:3px solid var(--blue);padding:8px 16px;margin:12px 0;background:var(--blue-light);border-radius:0 6px 6px 0;font-style:italic;}
.lh-content-body table{width:100%;border-collapse:collapse;margin:12px 0;}
.lh-content-body th,.lh-content-body td{padding:8px 12px;border:1px solid var(--g200);font-size:13px;}
.lh-content-body th{background:var(--g50);font-weight:600;}
/* Quiz */
.lh-quiz-q{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--g100);}
.lh-quiz-q:last-child{border-bottom:none;margin-bottom:0;}
.lh-quiz-q-header{display:flex;gap:8px;align-items:center;margin-bottom:6px;}
.lh-quiz-q-num{font-size:12px;font-weight:700;color:var(--blue);background:var(--blue-light);padding:2px 8px;border-radius:6px;}
.lh-quiz-q-type{font-size:11px;color:var(--g400);}
.lh-quiz-q-text{font-size:14px;font-weight:500;margin-bottom:10px;color:var(--g800);}
.lh-quiz-options{display:flex;flex-direction:column;gap:6px;}
.lh-quiz-option{display:flex;align-items:center;gap:10px;padding:10px 14px;border:1.5px solid var(--g200);border-radius:8px;cursor:pointer;transition:all 0.15s;font-size:13px;}
.lh-quiz-option:hover{border-color:var(--blue-light);background:var(--g50);}
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:16px;height:16px;}
.lh-quiz-option span{flex:1;}
.lh-opt-correct{border-color:var(--green) !important;background:var(--green-light) !important;}
.lh-opt-wrong{border-color:var(--red) !important;background:var(--red-light) !important;}
/* Quiz results */
.lh-result-item{margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--g100);}
.lh-result-item:last-child{border-bottom:none;}
.lh-result-header{font-size:14px;margin-bottom:6px;}
.lh-expl{font-size:13px;padding:8px 12px;border-radius:6px;margin-top:6px;}
.lh-expl-wrong{background:var(--red-light);color:#991b1b;}
.lh-expl-correct{background:var(--green-light);color:#166534;}
.lh-expl-general{background:var(--blue-light);color:#1e40af;}
/* CMS question builder */
.lh-question-block{padding:12px;border:1px solid var(--g200);border-radius:8px;margin-bottom:8px;background:var(--g50);}
.lh-option-row{display:flex;align-items:center;gap:6px;margin-bottom:4px;}
@media(max-width:640px){
.lh-feed-item-header{flex-direction:column;gap:6px;}
.lh-feed-badges{align-self:flex-start;}
.lh-option-row{flex-wrap:wrap;}
.lh-option-row .lh-opt-expl{width:100%;}
}

View file

@ -195,6 +195,10 @@
<i class="fas fa-rotate"></i>
<span>Catch-Up Schedule</span>
</button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<span class="sidebar-section-label">Account</span>
<button class="tab-btn" data-tab="settings">
<i class="fas fa-cog"></i>
@ -739,6 +743,72 @@
</div>
</section>
<!-- ===== TAB: LEARNING HUB ===== -->
<section id="learning-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-graduation-cap"></i> Learning Hub</h2>
<p>Pediatric education, clinical pearls, and self-assessment quizzes</p>
</div>
<!-- Search bar -->
<div class="card" style="padding:12px 16px;">
<div style="display:flex;gap:8px;align-items:center;">
<i class="fas fa-search" style="color:var(--g400);"></i>
<input type="text" id="lh-search" class="full-input" placeholder="Search topics, subjects..." style="border:none;flex:1;">
</div>
</div>
<!-- Category pills -->
<div id="lh-categories" class="lh-category-bar"></div>
<!-- Content feed -->
<div id="lh-feed" class="lh-feed"></div>
<!-- Content viewer (hidden until item clicked) -->
<div id="lh-viewer" class="lh-viewer hidden">
<button id="lh-back" class="btn-sm btn-ghost" style="margin-bottom:12px;"><i class="fas fa-arrow-left"></i> Back to Feed</button>
<div class="card">
<div class="card-header">
<h3 id="lh-viewer-title"></h3>
<span id="lh-viewer-meta" style="font-size:12px;color:var(--g500);"></span>
</div>
<div id="lh-viewer-body" class="lh-content-body" style="padding:16px;"></div>
</div>
<!-- Quiz section (if content has questions) -->
<div id="lh-quiz-section" class="hidden" style="margin-top:16px;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clipboard-question"></i> Quiz</h3>
<span id="lh-quiz-count" style="font-size:12px;color:var(--g500);"></span>
</div>
<div id="lh-quiz-questions" style="padding:16px;"></div>
<div style="padding:0 16px 16px;">
<button id="lh-submit-quiz" class="btn-generate"><i class="fas fa-check"></i> Submit Answers</button>
</div>
</div>
<!-- Results -->
<div id="lh-quiz-results" class="hidden" style="margin-top:12px;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-bar"></i> Results</h3>
<span id="lh-quiz-score" class="model-tag"></span>
</div>
<div id="lh-quiz-explanations" style="padding:16px;"></div>
</div>
</div>
</div>
<!-- Past attempts -->
<div id="lh-progress-section" class="hidden" style="margin-top:12px;">
<div class="card" style="padding:12px 16px;">
<h4 style="margin:0 0 8px;font-size:13px;color:var(--g600);"><i class="fas fa-history"></i> Your Past Attempts</h4>
<div id="lh-progress-list" style="font-size:13px;"></div>
</div>
</div>
</div>
</section>
<!-- ===== TAB: ADMIN ===== -->
<section id="admin-tab" class="tab-content">
<div class="module-header">
@ -945,6 +1015,98 @@
</div>
</div>
<!-- ── CMS: Learning Hub ──────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-graduation-cap"></i> Learning Hub CMS</h3>
<span id="lh-cms-stats" style="font-size:12px;color:var(--g500);"></span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
<!-- Categories management -->
<div>
<h4 style="margin:0 0 8px;font-size:14px;"><i class="fas fa-folder"></i> Categories</h4>
<div id="lh-cms-categories" style="margin-bottom:8px;"></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="text" id="lh-cms-cat-name" placeholder="New category name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;">
<button id="btn-lh-add-cat" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Category</button>
</div>
</div>
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
<!-- Content management -->
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h4 style="margin:0;font-size:14px;"><i class="fas fa-file-alt"></i> Content</h4>
<button id="btn-lh-new-content" class="btn-sm btn-primary"><i class="fas fa-plus"></i> New Content</button>
</div>
<div id="lh-cms-content-list" style="max-height:400px;overflow-y:auto;"></div>
</div>
</div>
</div>
<!-- Learning Hub Content Editor (hidden by default) -->
<div id="lh-cms-editor" class="card hidden">
<div class="card-header">
<h3 id="lh-cms-editor-title"><i class="fas fa-edit"></i> New Content</h3>
<button id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-times"></i> Close</button>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
<input type="hidden" id="lh-cms-edit-id" value="">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Title</label>
<input type="text" id="lh-cms-edit-title" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Content title">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Subject</label>
<input type="text" id="lh-cms-edit-subject" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g., Development, Cardiology">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Category</label>
<select id="lh-cms-edit-category" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
<option value="">Uncategorized</option>
</select>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Type</label>
<select id="lh-cms-edit-type" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
<option value="article">Article</option>
<option value="quiz">Quiz Only</option>
<option value="pearl">Clinical Pearl</option>
</select>
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Body (supports basic HTML)</label>
<textarea id="lh-cms-edit-body" rows="10" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;font-family:inherit;" placeholder="Content body..."></textarea>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;">Published:</label>
<select id="lh-cms-edit-published" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="false">Draft</option>
<option value="true">Published</option>
</select>
</div>
<!-- Quiz Questions Builder -->
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h4 style="margin:0;font-size:14px;"><i class="fas fa-clipboard-question"></i> Quiz Questions</h4>
<button id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
</div>
<div id="lh-cms-questions"></div>
</div>
<div style="display:flex;gap:8px;">
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Content</button>
<button id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;"><i class="fas fa-trash"></i> Delete</button>
</div>
</div>
</div>
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
<div class="card" style="border:1px solid var(--red-light);">
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
@ -1516,6 +1678,7 @@
<script src="/js/sickVisit.js"></script>
<script src="/js/encounters.js"></script>
<script src="/js/memories.js"></script>
<script src="/js/learningHub.js"></script>
<script src="/js/admin.js"></script>
</body>

View file

@ -25,8 +25,35 @@
});
function loadAdmin() {
loadSettings();
loadUsers();
var isModerator = window._userRole === 'moderator';
// Moderators only see Learning Hub CMS; hide other admin sections
var adminTab = document.getElementById('admin-tab');
if (adminTab) {
adminTab.querySelectorAll('.card, .admin-stats').forEach(function(card) {
// Show all for admin
if (!isModerator) { card.style.display = ''; return; }
// For moderator: only show Learning Hub CMS cards and editor
var header = card.querySelector('.card-header h3');
var headerText = header ? header.textContent.trim() : '';
var isLearning = headerText.indexOf('Learning Hub') !== -1;
var isEditor = card.id === 'lh-cms-editor';
if (isLearning || isEditor) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Hide module-header subtitle for moderators
var moduleHeader = adminTab.querySelector('.module-header');
if (moduleHeader && isModerator) {
moduleHeader.querySelector('h2').innerHTML = '<i class="fas fa-pen-to-square"></i> Content Manager';
moduleHeader.querySelector('p').textContent = 'Create and manage Learning Hub content';
}
}
if (!isModerator) {
loadSettings();
loadUsers();
}
}
// ---- SETTINGS + STATS ----
@ -109,7 +136,7 @@
tbody.innerHTML = users.map(function(u) {
var isSelf = currentUser.id && u.id === currentUser.id;
var roleColor = u.role === 'admin' ? 'var(--blue)' : 'var(--g500)';
var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)';
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
@ -129,6 +156,12 @@
} else {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
}
if (u.role !== 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-moderator" data-id="' + u.id + '" data-email="' + esc(u.email) + '" style="color:var(--purple);">Set Moderator</button>');
}
if (u.role === 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-user" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Set User</button>');
}
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
} else {
@ -189,6 +222,20 @@
});
break;
case 'set-moderator':
if (!confirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() {
showToast(email + ' is now a moderator', 'success'); loadUsers();
});
break;
case 'set-user':
if (!confirm('Set ' + email + ' to full user role?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' is now a user', 'success'); loadUsers();
});
break;
case 'reset-pw':
var newPw = prompt('New password for ' + email + ' (8+ characters):');
if (!newPw) return;

View file

@ -82,15 +82,23 @@ document.addEventListener('DOMContentLoaded', function() {
}
if (userName) userName.textContent = user.name || user.email;
// Show admin tab for admins
// Show admin tab for admins; show as "Content Manager" for moderators
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) {
if (user.role === 'admin') {
adminTabBtn.classList.remove('hidden');
adminTabBtn.querySelector('span').textContent = 'Admin';
adminTabBtn.querySelector('i').className = 'fas fa-user-shield';
} else if (user.role === 'moderator') {
adminTabBtn.classList.remove('hidden');
adminTabBtn.querySelector('span').textContent = 'Content Manager';
adminTabBtn.querySelector('i').className = 'fas fa-pen-to-square';
} else {
adminTabBtn.classList.add('hidden');
}
}
// Store role globally for JS modules to check
window._userRole = user.role;
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);

747
public/js/learningHub.js Normal file
View file

@ -0,0 +1,747 @@
// ============================================================
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
// ============================================================
(function() {
var loaded = false;
var cmsLoaded = false;
var currentContent = null;
var currentView = 'feed'; // 'feed' | 'category' | 'viewer'
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'learning') {
if (!loaded) { loadFeed(); loadCategories(); loaded = true; }
}
// Load CMS for both admin and moderator when they open admin/content manager tab
if (e.detail && e.detail.tab === 'admin') {
if (!cmsLoaded && (window._userRole === 'admin' || window._userRole === 'moderator')) {
loadCms(); cmsLoaded = true;
}
}
});
// ── Event delegation ───────────────────────────────────────
document.addEventListener('click', function(e) {
// Feed item click
var feedItem = e.target.closest('.lh-feed-item');
if (feedItem && feedItem.dataset.slug) {
loadContent(feedItem.dataset.slug);
return;
}
// Category pill click
var catPill = e.target.closest('.lh-cat-pill');
if (catPill && catPill.dataset.slug) {
if (catPill.dataset.slug === 'all') {
loadFeed();
setActiveCatPill('all');
} else {
loadCategoryContent(catPill.dataset.slug);
setActiveCatPill(catPill.dataset.slug);
}
return;
}
// Back button
if (e.target.closest('#lh-back')) {
showFeed();
return;
}
// Submit quiz
if (e.target.closest('#lh-submit-quiz')) {
submitQuiz();
return;
}
// ── CMS events ─────────────────────────────
if (e.target.closest('#btn-lh-add-cat')) { addCategory(); return; }
if (e.target.closest('#btn-lh-new-content')) { openEditor(null); return; }
if (e.target.closest('#btn-lh-close-editor')) { closeEditor(); return; }
if (e.target.closest('#btn-lh-save-content')) { saveContent(); return; }
if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; }
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
// CMS content item edit
var cmsItem = e.target.closest('.lh-cms-content-item');
if (cmsItem && cmsItem.dataset.id) { openEditor(cmsItem.dataset.id); return; }
// CMS delete category
var delCat = e.target.closest('.lh-cms-del-cat');
if (delCat) { deleteCategory(delCat.dataset.id); return; }
// Remove question block
var rmQ = e.target.closest('.lh-rm-question');
if (rmQ) { var qb = rmQ.closest('.lh-question-block'); if (qb) qb.remove(); return; }
// Add option to question
var addOpt = e.target.closest('.lh-add-option');
if (addOpt) {
var qBlock = addOpt.closest('.lh-question-block');
if (qBlock) addOptionRow(qBlock.querySelector('.lh-options-list'), null);
return;
}
// Remove option
var rmOpt = e.target.closest('.lh-rm-option');
if (rmOpt) { var row = rmOpt.closest('.lh-option-row'); if (row) row.remove(); return; }
});
// Search
var searchTimeout = null;
var searchEl = document.getElementById('lh-search');
if (searchEl) {
searchEl.addEventListener('input', function() {
clearTimeout(searchTimeout);
var q = searchEl.value.trim();
if (!q) { loadFeed(); return; }
searchTimeout = setTimeout(function() { searchContent(q); }, 300);
});
}
// ============================================================
// USER-FACING: FEED, CATEGORIES, CONTENT, QUIZZES
// ============================================================
function loadFeed() {
var feedEl = document.getElementById('lh-feed');
if (!feedEl) return;
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
showFeed();
fetch('/api/learning/feed', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
renderFeed(data.content, feedEl);
})
.catch(function() { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Connection error</p>'; });
}
function loadCategories() {
fetch('/api/learning/categories', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var bar = document.getElementById('lh-categories');
if (!bar) return;
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
data.categories.forEach(function(c) {
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
});
bar.innerHTML = html;
});
}
function loadCategoryContent(slug) {
var feedEl = document.getElementById('lh-feed');
if (!feedEl) return;
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
showFeed();
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
renderFeed(data.content, feedEl);
});
}
function searchContent(q) {
var feedEl = document.getElementById('lh-feed');
if (!feedEl) return;
showFeed();
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
renderFeed(data.content, feedEl);
});
}
function renderFeed(items, feedEl) {
if (!items || items.length === 0) {
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
return;
}
feedEl.innerHTML = items.map(function(item) {
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : 'fa-file-alt';
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : 'var(--blue)';
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
'<div class="lh-feed-item-header">' +
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
'<div style="flex:1;min-width:0;">' +
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
'<div class="lh-feed-meta">' +
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
'<span>' + date + '</span>' +
'</div>' +
'</div>' +
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
'</div>' +
'</div>';
}).join('');
}
function loadContent(slug) {
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
currentContent = data.content;
showViewer(data.content);
})
.catch(function() { showToast('Failed to load content', 'error'); });
}
function showFeed() {
currentView = 'feed';
var feedEl = document.getElementById('lh-feed');
var viewerEl = document.getElementById('lh-viewer');
var catsEl = document.getElementById('lh-categories');
var searchWrap = document.getElementById('lh-search');
if (feedEl) feedEl.classList.remove('hidden');
if (viewerEl) viewerEl.classList.add('hidden');
if (catsEl) catsEl.classList.remove('hidden');
if (searchWrap) searchWrap.closest('.card').classList.remove('hidden');
}
function showViewer(item) {
currentView = 'viewer';
var feedEl = document.getElementById('lh-feed');
var viewerEl = document.getElementById('lh-viewer');
var catsEl = document.getElementById('lh-categories');
var searchWrap = document.getElementById('lh-search');
if (feedEl) feedEl.classList.add('hidden');
if (viewerEl) viewerEl.classList.remove('hidden');
if (catsEl) catsEl.classList.add('hidden');
if (searchWrap) searchWrap.closest('.card').classList.add('hidden');
var titleEl = document.getElementById('lh-viewer-title');
var metaEl = document.getElementById('lh-viewer-meta');
var bodyEl = document.getElementById('lh-viewer-body');
var quizSection = document.getElementById('lh-quiz-section');
var resultsSection = document.getElementById('lh-quiz-results');
var progressSection = document.getElementById('lh-progress-section');
if (titleEl) titleEl.textContent = item.title;
if (metaEl) {
var parts = [];
if (item.category_name) parts.push(item.category_name);
if (item.subject) parts.push(item.subject);
if (item.author_name) parts.push('by ' + item.author_name);
if (item.created_at) parts.push(new Date(item.created_at).toLocaleDateString());
metaEl.textContent = parts.join(' | ');
}
if (bodyEl) {
// Render body as HTML (admin creates it)
bodyEl.innerHTML = sanitizeHtml(item.body || '');
}
// Quiz
if (quizSection) {
if (item.questions && item.questions.length > 0) {
quizSection.classList.remove('hidden');
renderQuiz(item.questions);
} else {
quizSection.classList.add('hidden');
}
}
if (resultsSection) resultsSection.classList.add('hidden');
// Progress
if (progressSection && item.progress && item.progress.length > 0) {
progressSection.classList.remove('hidden');
var progHtml = item.progress.map(function(p) {
var pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
var date = new Date(p.completed_at).toLocaleDateString();
return '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--g100);">' +
'<span>' + date + '</span>' +
'<span style="font-weight:600;color:' + (pct >= 70 ? 'var(--green)' : 'var(--amber)') + ';">' + p.score + '/' + p.total + ' (' + pct + '%)</span>' +
'</div>';
}).join('');
document.getElementById('lh-progress-list').innerHTML = progHtml;
} else if (progressSection) {
progressSection.classList.add('hidden');
}
}
function renderQuiz(questions) {
var container = document.getElementById('lh-quiz-questions');
var countEl = document.getElementById('lh-quiz-count');
if (!container) return;
if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
container.innerHTML = questions.map(function(q, idx) {
var typeLabel = q.question_type === 'true_false' ? 'True / False' : 'Multiple Choice';
var optionsHtml = (q.options || []).map(function(opt) {
return '<label class="lh-quiz-option">' +
'<input type="radio" name="lh-q-' + q.id + '" value="' + opt.id + '">' +
'<span>' + esc(opt.option_text) + '</span>' +
'</label>';
}).join('');
return '<div class="lh-quiz-q" data-qid="' + q.id + '">' +
'<div class="lh-quiz-q-header">' +
'<span class="lh-quiz-q-num">Q' + (idx + 1) + '</span>' +
'<span class="lh-quiz-q-type">' + typeLabel + '</span>' +
'</div>' +
'<p class="lh-quiz-q-text">' + esc(q.question_text) + '</p>' +
'<div class="lh-quiz-options">' + optionsHtml + '</div>' +
'</div>';
}).join('');
}
function submitQuiz() {
if (!currentContent || !currentContent.questions) return;
var answers = [];
currentContent.questions.forEach(function(q) {
var selected = document.querySelector('input[name="lh-q-' + q.id + '"]:checked');
answers.push({
questionId: q.id,
optionId: selected ? parseInt(selected.value) : null
});
});
showLoading('Submitting quiz...');
fetch('/api/learning/submit-quiz', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ contentId: currentContent.id, answers: answers })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
showQuizResults(data);
})
.catch(function() { hideLoading(); showToast('Submit failed', 'error'); });
}
function showQuizResults(data) {
var resultsEl = document.getElementById('lh-quiz-results');
var scoreEl = document.getElementById('lh-quiz-score');
var explEl = document.getElementById('lh-quiz-explanations');
if (!resultsEl) return;
resultsEl.classList.remove('hidden');
var pct = data.percentage;
var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
if (scoreEl) {
scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
scoreEl.style.background = color;
scoreEl.style.color = 'white';
}
if (explEl) {
explEl.innerHTML = data.results.map(function(r, idx) {
var icon = r.isCorrect ? '<i class="fas fa-check-circle" style="color:var(--green);"></i>' : '<i class="fas fa-times-circle" style="color:var(--red);"></i>';
var explHtml = '';
if (!r.isCorrect && r.selectedExplanation) {
explHtml += '<div class="lh-expl lh-expl-wrong"><strong>Why incorrect:</strong> ' + esc(r.selectedExplanation) + '</div>';
}
if (!r.isCorrect) {
explHtml += '<div class="lh-expl lh-expl-correct"><strong>Correct answer:</strong> ' + esc(r.correctOptionText) + '</div>';
}
if (r.generalExplanation) {
explHtml += '<div class="lh-expl lh-expl-general"><strong>Explanation:</strong> ' + esc(r.generalExplanation) + '</div>';
}
return '<div class="lh-result-item">' +
'<div class="lh-result-header">' + icon + ' <strong>Q' + (idx + 1) + ':</strong> ' + esc(r.questionText) + '</div>' +
explHtml +
'</div>';
}).join('');
}
// Highlight correct/incorrect in quiz
data.results.forEach(function(r) {
var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
if (!qDiv) return;
qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
var input = label.querySelector('input');
if (!input) return;
var optId = parseInt(input.value);
if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
input.disabled = true;
});
});
// Disable submit button
var submitBtn = document.getElementById('lh-submit-quiz');
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fas fa-check"></i> Submitted'; }
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function setActiveCatPill(slug) {
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
p.classList.toggle('active', p.dataset.slug === slug);
});
}
// ============================================================
// ADMIN CMS
// ============================================================
function loadCms() {
loadCmsCategories();
loadCmsContent();
loadCmsStats();
}
function loadCmsStats() {
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var el = document.getElementById('lh-cms-stats');
if (el) el.textContent = data.stats.publishedContent + ' published, ' + data.stats.totalContent + ' total, ' + data.stats.totalCategories + ' categories, ' + data.stats.totalAttempts + ' quiz attempts';
});
}
function loadCmsCategories() {
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var container = document.getElementById('lh-cms-categories');
if (!container) return;
if (data.categories.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No categories yet.</p>';
} else {
container.innerHTML = data.categories.map(function(c) {
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
'<span style="font-size:13px;font-weight:500;flex:1;">' + esc(c.name) + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + c.content_count + ' items</span>' +
'<button class="btn-sm btn-ghost lh-cms-del-cat" data-id="' + c.id + '" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i></button>' +
'</div>';
}).join('');
}
// Update category dropdown in editor
var sel = document.getElementById('lh-cms-edit-category');
if (sel) {
sel.innerHTML = '<option value="">Uncategorized</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
}
});
}
function addCategory() {
var input = document.getElementById('lh-cms-cat-name');
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
fetch('/api/admin/learning/categories', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: input.value.trim() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
input.value = '';
loadCmsCategories();
loadCategories(); // refresh user-facing pills too
showToast('Category created', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
function deleteCategory(id) {
if (!confirm('Delete this category? Content will become uncategorized.')) return;
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
else showToast(data.error || 'Failed', 'error');
});
}
function loadCmsContent() {
var container = document.getElementById('lh-cms-content-list');
if (!container) return;
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
fetch('/api/admin/learning/content', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
if (data.content.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">No content yet. Click "New Content" to create.</p>';
return;
}
container.innerHTML = data.content.map(function(item) {
var statusBadge = item.published
? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">Published</span>'
: '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g500);">Draft</span>';
var qBadge = item.question_count > 0 ? ' <span style="font-size:11px;color:var(--amber);">' + item.question_count + 'Q</span>' : '';
return '<div class="lh-cms-content-item" data-id="' + item.id + '" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--g100);cursor:pointer;">' +
'<div style="flex:1;min-width:0;">' +
'<div style="font-size:13px;font-weight:500;">' + esc(item.title) + qBadge + '</div>' +
'<div style="font-size:11px;color:var(--g500);">' + (item.category_name || 'Uncategorized') + (item.subject ? ' | ' + esc(item.subject) : '') + '</div>' +
'</div>' +
statusBadge +
'<i class="fas fa-chevron-right" style="color:var(--g300);font-size:12px;"></i>' +
'</div>';
}).join('');
});
}
function openEditor(contentId) {
var editor = document.getElementById('lh-cms-editor');
if (!editor) return;
editor.classList.remove('hidden');
// Reset
document.getElementById('lh-cms-edit-id').value = '';
document.getElementById('lh-cms-edit-title').value = '';
document.getElementById('lh-cms-edit-subject').value = '';
document.getElementById('lh-cms-edit-body').value = '';
document.getElementById('lh-cms-edit-category').value = '';
document.getElementById('lh-cms-edit-type').value = 'article';
document.getElementById('lh-cms-edit-published').value = 'false';
document.getElementById('lh-cms-questions').innerHTML = '';
document.getElementById('lh-cms-editor-title').innerHTML = '<i class="fas fa-edit"></i> New Content';
document.getElementById('btn-lh-delete-content').classList.add('hidden');
if (!contentId) {
editor.scrollIntoView({ behavior: 'smooth' });
return;
}
// Load existing content
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { showToast('Failed to load', 'error'); return; }
var c = data.content;
document.getElementById('lh-cms-edit-id').value = c.id;
document.getElementById('lh-cms-edit-title').value = c.title;
document.getElementById('lh-cms-edit-subject').value = c.subject || '';
document.getElementById('lh-cms-edit-body').value = c.body || '';
document.getElementById('lh-cms-edit-category').value = c.category_id || '';
document.getElementById('lh-cms-edit-type').value = c.content_type || 'article';
document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false';
document.getElementById('lh-cms-editor-title').innerHTML = '<i class="fas fa-edit"></i> Edit: ' + esc(c.title);
document.getElementById('btn-lh-delete-content').classList.remove('hidden');
// Load questions
var qContainer = document.getElementById('lh-cms-questions');
qContainer.innerHTML = '';
if (c.questions && c.questions.length > 0) {
c.questions.forEach(function(q) { addQuestionBlock(q); });
}
editor.scrollIntoView({ behavior: 'smooth' });
});
}
function closeEditor() {
var editor = document.getElementById('lh-cms-editor');
if (editor) editor.classList.add('hidden');
}
function saveContent() {
var id = document.getElementById('lh-cms-edit-id').value;
var title = document.getElementById('lh-cms-edit-title').value.trim();
var subject = document.getElementById('lh-cms-edit-subject').value.trim();
var body = document.getElementById('lh-cms-edit-body').value;
var category_id = document.getElementById('lh-cms-edit-category').value || null;
var content_type = document.getElementById('lh-cms-edit-type').value;
var published = document.getElementById('lh-cms-edit-published').value === 'true';
if (!title) { showToast('Title required', 'error'); return; }
// Gather questions
var questions = [];
document.querySelectorAll('#lh-cms-questions .lh-question-block').forEach(function(qBlock) {
var qText = qBlock.querySelector('.lh-q-text').value.trim();
if (!qText) return;
var qType = qBlock.querySelector('.lh-q-type').value;
var qExpl = qBlock.querySelector('.lh-q-explanation').value.trim();
var qId = qBlock.dataset.questionId || null;
var options = [];
qBlock.querySelectorAll('.lh-option-row').forEach(function(row) {
var optText = row.querySelector('.lh-opt-text').value.trim();
if (!optText) return;
options.push({
option_text: optText,
is_correct: row.querySelector('.lh-opt-correct').checked,
explanation: row.querySelector('.lh-opt-expl').value.trim()
});
});
questions.push({
id: qId,
question_text: qText,
question_type: qType,
explanation: qExpl,
options: options
});
});
var payload = { title: title, body: body, category_id: category_id, subject: subject, content_type: content_type, published: published };
var method = id ? 'PUT' : 'POST';
var url = id ? '/api/admin/learning/content/' + id : '/api/admin/learning/content';
showLoading('Saving...');
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
var contentId = id || data.id;
// Save questions sequentially
saveQuestions(contentId, questions, 0, function() {
hideLoading();
showToast('Content saved', 'success');
closeEditor();
loadCmsContent();
loadCmsStats();
loaded = false; // force refresh user feed
});
})
.catch(function() { hideLoading(); showToast('Save failed', 'error'); });
}
function saveQuestions(contentId, questions, index, callback) {
if (index >= questions.length) { callback(); return; }
var q = questions[index];
var qId = q.id;
var method, url;
if (qId) {
method = 'PUT';
url = '/api/admin/learning/questions/' + qId;
} else {
method = 'POST';
url = '/api/admin/learning/content/' + contentId + '/questions';
}
fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify({
question_text: q.question_text,
question_type: q.question_type,
explanation: q.explanation,
options: q.options
})
})
.then(function(r) { return r.json(); })
.then(function() {
saveQuestions(contentId, questions, index + 1, callback);
})
.catch(function() {
saveQuestions(contentId, questions, index + 1, callback);
});
}
function deleteContent() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
if (!confirm('Delete this content and all its questions? This cannot be undone.')) return;
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Content deleted', 'info');
closeEditor();
loadCmsContent();
loadCmsStats();
loaded = false;
} else showToast(data.error || 'Failed', 'error');
});
}
function addQuestionBlock(existingQ) {
var container = document.getElementById('lh-cms-questions');
if (!container) return;
var block = document.createElement('div');
block.className = 'lh-question-block';
if (existingQ && existingQ.id) block.dataset.questionId = existingQ.id;
block.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">' +
'<span style="font-size:12px;font-weight:600;color:var(--g600);">Question</span>' +
'<button class="btn-sm btn-ghost lh-rm-question" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i> Remove</button>' +
'</div>' +
'<input type="text" class="lh-q-text" placeholder="Question text..." value="' + esc(existingQ ? existingQ.question_text : '') + '" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;margin-bottom:6px;">' +
'<div style="display:flex;gap:8px;margin-bottom:6px;">' +
'<select class="lh-q-type" style="font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
'<option value="mcq"' + (existingQ && existingQ.question_type === 'mcq' ? ' selected' : '') + '>Multiple Choice</option>' +
'<option value="true_false"' + (existingQ && existingQ.question_type === 'true_false' ? ' selected' : '') + '>True / False</option>' +
'</select>' +
'<input type="text" class="lh-q-explanation" placeholder="General explanation (shown after answering)" value="' + esc(existingQ ? existingQ.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
'</div>' +
'<div class="lh-options-list"></div>' +
'<button class="btn-sm btn-ghost lh-add-option" style="margin-top:4px;font-size:12px;"><i class="fas fa-plus"></i> Add Option</button>';
container.appendChild(block);
var optList = block.querySelector('.lh-options-list');
if (existingQ && existingQ.options && existingQ.options.length > 0) {
existingQ.options.forEach(function(opt) { addOptionRow(optList, opt); });
} else if (existingQ && existingQ.question_type === 'true_false') {
addOptionRow(optList, { option_text: 'True', is_correct: false, explanation: '' });
addOptionRow(optList, { option_text: 'False', is_correct: false, explanation: '' });
} else {
// Add 4 blank MCQ options
for (var i = 0; i < 4; i++) addOptionRow(optList, null);
}
}
function addOptionRow(container, opt) {
if (!container) return;
var row = document.createElement('div');
row.className = 'lh-option-row';
row.innerHTML =
'<label style="display:flex;align-items:center;gap:4px;cursor:pointer;" title="Mark as correct">' +
'<input type="checkbox" class="lh-opt-correct"' + (opt && opt.is_correct ? ' checked' : '') + '>' +
'<i class="fas fa-check" style="font-size:11px;color:var(--green);"></i>' +
'</label>' +
'<input type="text" class="lh-opt-text" placeholder="Option text" value="' + esc(opt ? opt.option_text : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
'<input type="text" class="lh-opt-expl" placeholder="Why wrong (optional)" value="' + esc(opt ? opt.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
'<button class="btn-sm btn-ghost lh-rm-option" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-times"></i></button>';
container.appendChild(row);
}
// ── Helpers ──────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function sanitizeHtml(html) {
// Allow basic formatting tags, strip scripts
var div = document.createElement('div');
div.innerHTML = html;
div.querySelectorAll('script,iframe,object,embed,form').forEach(function(el) { el.remove(); });
return div.innerHTML;
}
})();

View file

@ -100,6 +100,11 @@ app.use(express.static(path.join(__dirname, 'public'), {
// Routes
app.use('/api/auth', require('./src/routes/auth'));
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
// (moderators need access to /api/admin/learning but not other /api/admin routes)
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/admin', require('./src/routes/adminConfig'));
@ -119,7 +124,7 @@ app.get('/api/models', async (req, res) => {
const { activeProvider } = require('./src/utils/ai');
app.get('/api/health', (req, res) => {
res.json({
status: 'running', version: '3.0.0', provider: activeProvider,
status: 'running', version: '3.1.0', provider: activeProvider,
timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
@ -128,6 +133,9 @@ app.get('/api/health', (req, res) => {
});
});
// Learning Hub routes (all authenticated users can read content & take quizzes)
app.use('/api/learning', require('./src/routes/learningHub'));
// Authenticated feature routes
app.use('/api', require('./src/routes/transcribe'));
app.use('/api', require('./src/routes/hpi'));
@ -160,7 +168,7 @@ const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('');
console.log('==========================================');
console.log('🏥 PEDIATRIC AI SCRIBE v3.0');
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
console.log('==========================================');
console.log('🌐 http://localhost:' + PORT);
console.log('🤖 Provider: ' + activeProvider);

View file

@ -125,6 +125,63 @@ async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
-- Learning Hub tables
CREATE TABLE IF NOT EXISTS learning_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_content (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL,
subject TEXT DEFAULT '',
content_type TEXT DEFAULT 'article',
published BOOLEAN DEFAULT false,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_questions (
id SERIAL PRIMARY KEY,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
question_text TEXT NOT NULL,
question_type TEXT NOT NULL DEFAULT 'mcq',
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_options (
id SERIAL PRIMARY KEY,
question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_progress (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
score INTEGER,
total INTEGER,
completed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id);
CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published);
CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id);
CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id);
CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id);
`);
// Add columns if upgrading
@ -135,7 +192,17 @@ async function initDatabase() {
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)"
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)",
"CREATE TABLE IF NOT EXISTS learning_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_content (id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, body TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL, subject TEXT DEFAULT '', content_type TEXT DEFAULT 'article', published BOOLEAN DEFAULT false, author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_questions (id SERIAL PRIMARY KEY, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, question_text TEXT NOT NULL, question_type TEXT NOT NULL DEFAULT 'mcq', explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_options (id SERIAL PRIMARY KEY, question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, is_correct BOOLEAN DEFAULT false, explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_progress (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, score INTEGER, total INTEGER, completed_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published)",
"CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id)"
];
for (var i = 0; i < migrations.length; i++) {
try { await client.query(migrations[i]); } catch(e) {}

View file

@ -44,4 +44,12 @@ async function adminMiddleware(req, res, next) {
next();
}
module.exports = { authMiddleware, adminMiddleware, JWT_SECRET };
// Moderator or Admin middleware — for Learning Hub CMS
async function moderatorMiddleware(req, res, next) {
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'moderator')) {
return res.status(403).json({ error: 'Moderator or admin access required' });
}
next();
}
module.exports = { authMiddleware, adminMiddleware, moderatorMiddleware, JWT_SECRET };

View file

@ -111,8 +111,8 @@ router.post('/users/:id/role', async function(req, res) {
var userId = parseInt(req.params.id);
var { role } = req.body;
if (!role || (role !== 'admin' && role !== 'user')) {
return res.status(400).json({ error: 'Role must be "admin" or "user"' });
if (!role || (role !== 'admin' && role !== 'user' && role !== 'moderator')) {
return res.status(400).json({ error: 'Role must be "admin", "user", or "moderator"' });
}
// Prevent removing your own admin

278
src/routes/learningAdmin.js Normal file
View file

@ -0,0 +1,278 @@
// ============================================================
// LEARNING ADMIN ROUTES — CMS for categories, content, quizzes
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
router.use(moderatorMiddleware);
// ── Helpers ──────────────────────────────────────────────────
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 100);
}
async function uniqueSlug(table, base) {
var slug = slugify(base);
var exists = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [slug]);
if (!exists) return slug;
var i = 2;
while (true) {
var attempt = slug + '-' + i;
var exists2 = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [attempt]);
if (!exists2) return attempt;
i++;
if (i > 100) return slug + '-' + Date.now();
}
}
// ============================================================
// CATEGORIES — CRUD
// ============================================================
router.get('/categories', async function(req, res) {
try {
var categories = await db.all(
`SELECT c.*, (SELECT COUNT(*) FROM learning_content lc WHERE lc.category_id = c.id) as content_count
FROM learning_categories c ORDER BY c.sort_order ASC, c.name ASC`,
[]
);
res.json({ success: true, categories: categories });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.post('/categories', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
var slug = await uniqueSlug('learning_categories', name.trim());
var result = await db.run(
'INSERT INTO learning_categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)',
[name.trim(), slug, description || '', sort_order || 0]
);
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.put('/categories/:id', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
var cat = await db.get('SELECT * FROM learning_categories WHERE id = ?', [req.params.id]);
if (!cat) return res.status(404).json({ error: 'Category not found' });
await db.run(
'UPDATE learning_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?',
[name || cat.name, description !== undefined ? description : cat.description, sort_order !== undefined ? sort_order : cat.sort_order, cat.id]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.delete('/categories/:id', async function(req, res) {
try {
// Set content in this category to uncategorized (NULL)
await db.run('UPDATE learning_content SET category_id = NULL WHERE category_id = ?', [req.params.id]);
await db.run('DELETE FROM learning_categories WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// CONTENT — CRUD
// ============================================================
router.get('/content', async function(req, res) {
try {
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.published, c.category_id,
c.created_at, c.updated_at,
cat.name as category_name,
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
ORDER BY c.updated_at DESC`,
[]
);
res.json({ success: true, content: content });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.get('/content/:id', async function(req, res) {
try {
var item = await db.get(
`SELECT c.*, cat.name as category_name
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.id = ?`,
[req.params.id]
);
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++) {
questions[i].options = await db.all(
'SELECT * FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[questions[i].id]
);
}
item.questions = questions;
res.json({ success: true, content: item });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.post('/content', async function(req, res) {
try {
var { title, body, category_id, subject, content_type, published } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' });
var slug = await uniqueSlug('learning_content', title.trim());
var result = await db.run(
'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
);
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.put('/content/:id', async function(req, res) {
try {
var item = await db.get('SELECT * FROM learning_content WHERE id = ?', [req.params.id]);
if (!item) return res.status(404).json({ error: 'Content not found' });
var { title, body, category_id, subject, content_type, published } = req.body;
await db.run(
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
[
title !== undefined ? title : item.title,
body !== undefined ? body : item.body,
category_id !== undefined ? (category_id || null) : item.category_id,
subject !== undefined ? subject : item.subject,
content_type !== undefined ? content_type : item.content_type,
published !== undefined ? published : item.published,
item.id
]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.delete('/content/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_content WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// QUESTIONS — CRUD (nested under content)
// ============================================================
router.post('/content/:contentId/questions', async function(req, res) {
try {
var { question_text, question_type, explanation, options } = req.body;
if (!question_text) return res.status(400).json({ error: 'Question text required' });
// Get max sort_order
var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]);
var sortOrder = (maxOrder ? maxOrder.mx : -1) + 1;
var result = await db.run(
'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[req.params.contentId, question_text, question_type || 'mcq', explanation || '', sortOrder]
);
var questionId = result.lastInsertRowid;
// Insert options if provided
if (options && Array.isArray(options)) {
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[questionId, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true, id: questionId });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.put('/questions/:id', async function(req, res) {
try {
var q = await db.get('SELECT * FROM learning_questions WHERE id = ?', [req.params.id]);
if (!q) return res.status(404).json({ error: 'Question not found' });
var { question_text, question_type, explanation, options } = req.body;
await db.run(
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
[question_text || q.question_text, question_type || q.question_type, explanation !== undefined ? explanation : q.explanation, q.id]
);
// Replace options if provided
if (options && Array.isArray(options)) {
await db.run('DELETE FROM learning_options WHERE question_id = ?', [q.id]);
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[q.id, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.delete('/questions/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_questions WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// STATS
// ============================================================
router.get('/stats', async function(req, res) {
try {
var totalContent = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var published = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
res.json({
success: true,
stats: {
totalContent: parseInt(totalContent.count),
publishedContent: parseInt(published.count),
totalCategories: parseInt(totalCategories.count),
totalQuizzes: parseInt(totalQuizzes.count),
totalAttempts: parseInt(totalAttempts.count)
}
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;

207
src/routes/learningHub.js Normal file
View file

@ -0,0 +1,207 @@
// ============================================================
// 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) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// 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) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// 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) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// 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) { 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
var questions = await db.all(
'SELECT q.id, q.question_text, q.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; });
var selectedOptionId = userAnswer ? userAnswer.optionId : null;
// 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 correctOption = options.find(function(o) { return o.is_correct; });
var selectedOption = selectedOptionId ? options.find(function(o) { return o.id === selectedOptionId; }) : null;
var isCorrect = selectedOption ? selectedOption.is_correct : false;
if (isCorrect) score++;
results.push({
questionId: q.id,
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 || ''
});
}
// 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) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// 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) { res.status(500).json({ error: err.message }); }
});
module.exports = router;