diff --git a/docker-compose.yml b/docker-compose.yml index de6d528..78eb3bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index 3c30b1f..f3c8738 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/css/styles.css b/public/css/styles.css index dddfa36..4ef8393 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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%;} +} diff --git a/public/index.html b/public/index.html index 44f08d7..7680b8c 100644 --- a/public/index.html +++ b/public/index.html @@ -195,6 +195,10 @@ Catch-Up Schedule + Account +
+
+

+ +
+
+
+ + + + + + + + +
@@ -945,6 +1015,98 @@
+ +
+
+

Learning Hub CMS

+ +
+
+ + +
+

Categories

+
+
+ + +
+
+ +
+ + +
+
+

Content

+ +
+
+
+
+
+ + + +

Reset Settings

@@ -1516,6 +1678,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 6c5991f..67144a7 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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 = ' 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(''); } + if (u.role !== 'moderator') { + actions.push(''); + } + if (u.role === 'moderator') { + actions.push(''); + } actions.push(''); actions.push(''); } 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; diff --git a/public/js/auth.js b/public/js/auth.js index 2ef9a5b..5c0e06f 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -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); diff --git a/public/js/learningHub.js b/public/js/learningHub.js new file mode 100644 index 0000000..bf53e15 --- /dev/null +++ b/public/js/learningHub.js @@ -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 = '

Loading...

'; + showFeed(); + + fetch('/api/learning/feed', { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) { feedEl.innerHTML = '

Failed to load

'; return; } + renderFeed(data.content, feedEl); + }) + .catch(function() { feedEl.innerHTML = '

Connection error

'; }); + } + + 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 = ''; + data.categories.forEach(function(c) { + html += ''; + }); + bar.innerHTML = html; + }); + } + + function loadCategoryContent(slug) { + var feedEl = document.getElementById('lh-feed'); + if (!feedEl) return; + feedEl.innerHTML = '

Loading...

'; + showFeed(); + + fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) { feedEl.innerHTML = '

Category not found

'; 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 = '
No content yet. Check back soon!
'; + 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 ? ' ' + item.question_count + ' Q' : ''; + var catBadge = item.category_name ? '' + esc(item.category_name) + '' : 'Uncategorized'; + var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : ''; + + return '
' + + '
' + + '' + + '
' + + '
' + esc(item.title) + '
' + + '
' + + (item.subject ? '' + esc(item.subject) + '' : '') + + (item.author_name ? 'by ' + esc(item.author_name) + '' : '') + + '' + date + '' + + '
' + + '
' + + '
' + catBadge + quizBadge + '
' + + '
' + + '
'; + }).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 '
' + + '' + date + '' + + '' + p.score + '/' + p.total + ' (' + pct + '%)' + + '
'; + }).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 ''; + }).join(''); + + return '
' + + '
' + + 'Q' + (idx + 1) + '' + + '' + typeLabel + '' + + '
' + + '

' + esc(q.question_text) + '

' + + '
' + optionsHtml + '
' + + '
'; + }).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 ? '' : ''; + var explHtml = ''; + if (!r.isCorrect && r.selectedExplanation) { + explHtml += '
Why incorrect: ' + esc(r.selectedExplanation) + '
'; + } + if (!r.isCorrect) { + explHtml += '
Correct answer: ' + esc(r.correctOptionText) + '
'; + } + if (r.generalExplanation) { + explHtml += '
Explanation: ' + esc(r.generalExplanation) + '
'; + } + + return '
' + + '
' + icon + ' Q' + (idx + 1) + ': ' + esc(r.questionText) + '
' + + explHtml + + '
'; + }).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 = ' 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 = '

No categories yet.

'; + } else { + container.innerHTML = data.categories.map(function(c) { + return '
' + + '' + esc(c.name) + '' + + '' + c.content_count + ' items' + + '' + + '
'; + }).join(''); + } + + // Update category dropdown in editor + var sel = document.getElementById('lh-cms-edit-category'); + if (sel) { + sel.innerHTML = ''; + data.categories.forEach(function(c) { + sel.innerHTML += ''; + }); + } + }); + } + + 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 = '

Loading...

'; + + 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 = '

No content yet. Click "New Content" to create.

'; + return; + } + container.innerHTML = data.content.map(function(item) { + var statusBadge = item.published + ? 'Published' + : 'Draft'; + var qBadge = item.question_count > 0 ? ' ' + item.question_count + 'Q' : ''; + + return '
' + + '
' + + '
' + esc(item.title) + qBadge + '
' + + '
' + (item.category_name || 'Uncategorized') + (item.subject ? ' | ' + esc(item.subject) : '') + '
' + + '
' + + statusBadge + + '' + + '
'; + }).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 = ' 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 = ' 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 = + '
' + + 'Question' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + ''; + + 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 = + '' + + '' + + '' + + ''; + container.appendChild(row); + } + + // ── Helpers ────────────────────────────────────────────────── + function esc(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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; + } + +})(); diff --git a/server.js b/server.js index c3cfbb4..3d5d003 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/src/db/database.js b/src/db/database.js index de533c8..4705af9 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -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) {} diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 10806b6..45645cc 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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 }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 0cc7fec..9936898 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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 diff --git a/src/routes/learningAdmin.js b/src/routes/learningAdmin.js new file mode 100644 index 0000000..b3749d5 --- /dev/null +++ b/src/routes/learningAdmin.js @@ -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; diff --git a/src/routes/learningHub.js b/src/routes/learningHub.js new file mode 100644 index 0000000..a4213d5 --- /dev/null +++ b/src/routes/learningHub.js @@ -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;