+
+
+ Processing... +
diff --git a/public/js/app.js b/public/js/app.js index dd452b9..c34be69 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -152,12 +152,18 @@ document.addEventListener('DOMContentLoaded', function() { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'settings') { if (typeof load2FAStatus === 'function') load2FAStatus(); + if (typeof loadSessions === 'function') loadSessions(); if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus(); if (typeof loadMemories === 'function') loadMemories(); if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList(); if (typeof renderAudioBackups === 'function') renderAudioBackups(); if (typeof loadDocuments === 'function') loadDocuments(); initBrowserWhisperSettings(); + // Wire HIBP check on change-password new password field + var pwNewEl = document.getElementById('pw-new'); + if (pwNewEl && typeof checkPasswordBreach === 'function') { + pwNewEl.onblur = function() { checkPasswordBreach(pwNewEl, 'pw-new-warning'); }; + } } }); @@ -426,6 +432,19 @@ function hideLoading() { if (overlay) overlay.style.display = 'none'; } +// Non-blocking busy bar — user can keep working while AI processes +function showBusy(text) { + var bar = document.getElementById('busy-bar'); + var textEl = document.getElementById('busy-text'); + if (textEl) textEl.textContent = text || 'Processing...'; + if (bar) bar.classList.add('active'); +} + +function hideBusy() { + var bar = document.getElementById('busy-bar'); + if (bar) bar.classList.remove('active'); +} + function showToast(message, type) { var container = document.getElementById('toast-container'); if (!container) { console.log('Toast:', type, message); return; } @@ -670,6 +689,12 @@ function _serverTranscribe(blob) { }); } +// Store original source material on an output element (call after generation) +function storeSourceContext(outputElementId, sourceText) { + var el = document.getElementById(outputElementId); + if (el && sourceText) el.dataset.sourceContext = sourceText; +} + function refineDocument(outputElementId, inputElementId) { var doc = document.getElementById(outputElementId); var input = document.getElementById(inputElementId); @@ -678,18 +703,21 @@ function refineDocument(outputElementId, inputElementId) { var instructions = input.value.trim(); if (!docText) { showToast('No document', 'error'); return; } if (!instructions) { showToast('Enter instructions', 'error'); return; } - showLoading('Refining...'); + showBusy('Refining...'); + var body = { currentDocument: docText, instructions: instructions, model: getSelectedModel() }; + // Include original source material so AI can reference the full input + if (doc.dataset.sourceContext) body.sourceContext = doc.dataset.sourceContext; fetch('/api/refine', { method: 'POST', headers: getAuthHeaders(), - body: JSON.stringify({ currentDocument: docText, instructions: instructions, model: getSelectedModel() }) + body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(doc, data.refined); input.value = ''; showToast('Refined!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function shortenDocument(outputElementId) { @@ -697,18 +725,18 @@ function shortenDocument(outputElementId) { if (!doc) return; var text = doc.innerText.trim(); if (!text) { showToast('No document', 'error'); return; } - showLoading('Shortening...'); + showBusy('Shortening...'); fetch('/api/shorten', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ document: text, model: getSelectedModel() }) }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(doc, data.shortened); showToast('Shortened!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function exportToNextcloud(elementId, docType) { diff --git a/public/js/auth.js b/public/js/auth.js index 642cbff..9d744f6 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -14,6 +14,7 @@ document.addEventListener('DOMContentLoaded', function() { var TOKEN_KEY = 'ped_scribe_token'; var USER_KEY = 'ped_scribe_user'; + var SESSION_KEY = 'ped_session_id'; // Auth module initialized @@ -30,13 +31,14 @@ document.addEventListener('DOMContentLoaded', function() { var ssoOk = urlParams.get('sso'); var ssoError = urlParams.get('error'); if (ssoOk === 'ok') { + var ssoSid = urlParams.get('sid'); history.replaceState(null, '', window.location.pathname); + if (ssoSid) localStorage.setItem(SESSION_KEY, ssoSid); // Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically) fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); }) .then(function(data) { if (data && data.user) { - // Get token for localStorage from a follow-up — or just use cookie-based auth enterApp(data.user, ''); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { showAuthScreen(); clearSession(); } @@ -169,6 +171,8 @@ document.addEventListener('DOMContentLoaded', function() { } function exitApp() { + // Destroy session server-side before clearing local state + fetch('/api/auth/logout', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin' }).catch(function() {}); clearSession(); document.documentElement.classList.remove('has-session'); history.replaceState(null, '', window.location.pathname); @@ -189,6 +193,7 @@ document.addEventListener('DOMContentLoaded', function() { function clearSession() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); + localStorage.removeItem(SESSION_KEY); window.AUTH_TOKEN = null; } @@ -348,6 +353,7 @@ document.addEventListener('DOMContentLoaded', function() { } if (data.success && data.token && data.user) { + if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId); enterApp(data.user, data.token); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { @@ -437,6 +443,7 @@ document.addEventListener('DOMContentLoaded', function() { hideLoading(); if (data.success && data.token && data.user) { + if (data.sessionId) localStorage.setItem(SESSION_KEY, data.sessionId); enterApp(data.user, data.token); showToast(data.message || 'Account created!', 'success'); } else if (data.success && data.needsVerification) { @@ -498,6 +505,143 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // ---- PASSWORD BREACH CHECK (inline, on blur) ---- + window.checkPasswordBreach = function checkPasswordBreach(inputEl, warningId) { + var pw = inputEl.value; + var warningEl = document.getElementById(warningId); + if (!warningEl) return; + if (!pw || pw.length < 8) { warningEl.style.display = 'none'; return; } + fetch('/api/auth/check-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.breached) { + warningEl.innerHTML = ' This password has appeared in ' + data.count.toLocaleString() + ' data breaches. You can still use it, but consider a different one.'; + warningEl.style.display = 'block'; + } else { + warningEl.style.display = 'none'; + } + }) + .catch(function() { warningEl.style.display = 'none'; }); + } + + var regPwInput = document.getElementById('reg-password'); + if (regPwInput) { + regPwInput.addEventListener('blur', function() { checkPasswordBreach(regPwInput, 'reg-pw-warning'); }); + } + var loginPwInput = document.getElementById('login-password'); + if (loginPwInput) { + loginPwInput.addEventListener('blur', function() { checkPasswordBreach(loginPwInput, 'login-pw-warning'); }); + } + + // ---- SESSION MANAGEMENT ---- + function timeAgo(date) { + var s = Math.floor((Date.now() - date.getTime()) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return Math.floor(s / 60) + 'm ago'; + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; + return Math.floor(s / 86400) + 'd ago'; + } + function escH(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } + + window.loadSessions = function() { + var list = document.getElementById('sessions-list'); + if (!list) return; + fetch('/api/sessions', { headers: getAuthHeaders(), credentials: 'same-origin' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success || !data.sessions || data.sessions.length === 0) { + list.innerHTML = '

No active sessions found.

'; + return; + } + var currentSid = data.currentSessionId || localStorage.getItem(SESSION_KEY); + list.innerHTML = data.sessions.map(function(s) { + var isCurrent = s.id === currentSid; + var created = new Date(s.created_at).toLocaleDateString(); + var lastAct = timeAgo(new Date(s.last_activity)); + return '
' + + '
' + + '
' + escH(s.device_label || 'Unknown device') + (isCurrent ? ' (this device)' : '') + '
' + + '
' + escH(s.ip_address || '') + ' · Created ' + created + ' · Active ' + lastAct + '
' + + '
' + + (isCurrent ? '' : '') + + '
'; + }).join(''); + + // Wire revoke buttons + list.querySelectorAll('.session-revoke-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + if (!confirm('Revoke this session? That device will be logged out.')) return; + fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success) { showToast('Session revoked', 'info'); loadSessions(); } + else showToast(d.error || 'Failed', 'error'); + }); + }); + }); + }) + .catch(function() { + list.innerHTML = '

Could not load sessions.

'; + }); + }; + + // Revoke all other sessions button + document.addEventListener('click', function(e) { + if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) { + e.preventDefault(); + if (!confirm('Revoke all other sessions? All other devices will be logged out.')) return; + fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); } + }); + } + }); + + // ---- CHANGE PASSWORD ---- + document.addEventListener('click', function(e) { + if (e.target.id === 'btn-change-password' || e.target.closest('#btn-change-password')) { + e.preventDefault(); + var current = document.getElementById('pw-current'); + var newPw = document.getElementById('pw-new'); + var confirm = document.getElementById('pw-confirm'); + var status = document.getElementById('pw-change-status'); + if (!current || !newPw || !confirm) return; + + if (!current.value || !newPw.value) { showToast('Fill in all fields', 'error'); return; } + if (newPw.value.length < 8) { showToast('New password must be 8+ characters', 'error'); return; } + if (newPw.value !== confirm.value) { showToast('Passwords do not match', 'error'); return; } + + if (status) { status.textContent = 'Changing...'; status.style.color = 'var(--g500)'; } + + fetch('/api/auth/change-password', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ currentPassword: current.value, newPassword: newPw.value }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showToast(data.message || 'Password changed', 'success'); + if (data.passwordWarning) setTimeout(function() { showToast(data.passwordWarning, 'warning'); }, 1500); + current.value = ''; newPw.value = ''; confirm.value = ''; + if (status) { status.textContent = ''; } + var warning = document.getElementById('pw-new-warning'); + if (warning) warning.style.display = 'none'; + if (typeof loadSessions === 'function') loadSessions(); + } else { + showToast(data.error || 'Failed', 'error'); + if (status) { status.textContent = ''; } + } + }) + .catch(function() { showToast('Connection error', 'error'); if (status) status.textContent = ''; }); + } + }); + // ---- 2FA STATUS ---- function load2FAStatus() { fetch('/api/auth/me', { headers: getAuthHeaders() }) diff --git a/public/js/chartReview.js b/public/js/chartReview.js index 13d38ed..fd65efe 100644 --- a/public/js/chartReview.js +++ b/public/js/chartReview.js @@ -134,7 +134,7 @@ showToast('Processing ' + totalNotes + ' notes — this may take a moment...', 'info'); } - showLoading('Generating chart review...'); + showBusy('Generating chart review...'); fetch('/api/generate-chart-review', { method: 'POST', @@ -154,16 +154,23 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(reviewText, data.review); + // Store source material for refine + var sourceItems = []; + visits.forEach(function(v) { sourceItems.push('VISIT (' + v.date + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); }); + subspecialty.forEach(function(v) { sourceItems.push('SUBSPECIALTY (' + v.date + ', ' + v.specialty + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); }); + edVisits.forEach(function(v) { sourceItems.push('ED VISIT (' + v.date + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); }); + labs.forEach(function(l) { sourceItems.push('LAB (' + l.date + '): ' + l.values); }); + storeSourceContext('cr-review-text', sourceItems.join('\n\n')); modelTag.textContent = (data.model || '').split('/').pop(); outputCard.classList.remove('hidden'); outputCard.scrollIntoView({ behavior: 'smooth' }); showToast('Chart review generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); document.getElementById('cr-refine-btn').addEventListener('click', function() { refineDocument('cr-review-text', 'cr-refine-input'); }); diff --git a/public/js/encounters.js b/public/js/encounters.js index a4a8940..87bc90d 100644 --- a/public/js/encounters.js +++ b/public/js/encounters.js @@ -419,7 +419,7 @@ var noteIdMap = { encounter: 'enc-hpi-text', dictation: 'dict-hpi-text', hospital: 'hc-course-text', chart: 'cr-review-text', - wellvisit: 'wv-note-text', soap: 'soap-text' + wellvisit: 'wv-note-text', sickvisit: 'sick-note-text', soap: 'soap-text' }; var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text')); diff --git a/public/js/hospitalCourse.js b/public/js/hospitalCourse.js index 46e3541..343788e 100644 --- a/public/js/hospitalCourse.js +++ b/public/js/hospitalCourse.js @@ -65,18 +65,18 @@ btn.innerHTML = ' Dictate'; btn.classList.remove('btn-recording'); - showLoading('Transcribing...'); + showBusy('Transcribing...'); rec.stop().then(function(blob) { - if (!blob) { hideLoading(); return; } + if (!blob) { hideBusy(); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { var existing = targetEl.innerText.trim(); targetEl.textContent = existing ? existing + '\n' + data.text : data.text; showToast('Transcribed!', 'success'); } }); - }).catch(function() { hideLoading(); }); + }).catch(function() { hideBusy(); }); } }); } @@ -132,7 +132,7 @@ return; } - showLoading('Generating hospital course...'); + showBusy('Generating hospital course...'); var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve(''); @@ -159,9 +159,16 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(courseText, data.hospitalCourse); + // Store source material for refine + var hcSource = []; + if (hAndP) hcSource.push('H&P (' + (hAndP.date || '') + '): ' + hAndP.content); + if (edNote) hcSource.push('ED NOTE (' + (edNote.date || '') + '): ' + edNote.content + (edNote.labs ? '\nLabs: ' + edNote.labs : '')); + notes.forEach(function(n) { hcSource.push('NOTE (' + (n.date || '') + ' ' + (n.type || '') + '): ' + n.content); }); + if (labs) hcSource.push('LABS: ' + labs); + storeSourceContext('hc-course-text', hcSource.join('\n\n')); formatTag.textContent = data.format || ''; modelTag.textContent = (data.model || '').split('/').pop(); outputCard.classList.remove('hidden'); @@ -170,7 +177,7 @@ showToast('Hospital course generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); // Refine, Shorten, Clarify @@ -181,7 +188,7 @@ var doc = courseText.innerText.trim(); if (!doc) { showToast('Generate first', 'error'); return; } - showLoading('Checking for missing info...'); + showBusy('Checking for missing info...'); fetch('/api/clarify', { method: 'POST', @@ -190,14 +197,14 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(clarifyOutput, data.questions); clarifyOutput.classList.remove('hidden'); showToast('Review missing information below', 'info'); } }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); // Register load handler for saved encounters diff --git a/public/js/learningHub.js b/public/js/learningHub.js index 793ebb8..7f9fb2e 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -838,7 +838,7 @@ var btn = document.getElementById('btn-lh-ai-generate'); if (btn) { btn.disabled = true; btn.innerHTML = ' Generating...'; } - showLoading('AI is generating content…'); + showBusy('AI is generating content...'); // FormData: no Content-Type header (browser sets it with boundary) var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; @@ -849,7 +849,7 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; } if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; } // Presentation returns marpMarkdown directly; others return content{} @@ -859,7 +859,7 @@ showToast('Content generated! Review and save.', 'success'); }) .catch(function(err) { - hideLoading(); + hideBusy(); if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; } showToast(err.message, 'error'); }); @@ -929,7 +929,7 @@ var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel(); toggleRefineBar(false); - showLoading('Refining content…'); + showBusy('Refining content...'); fetch('/api/admin/learning/ai-refine', { method: 'POST', @@ -938,12 +938,12 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; } if (_bodyEditor) _bodyEditor.commands.setContent(data.refined); showToast('Body refined!', 'success'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function loadCmsStats() { @@ -1577,22 +1577,22 @@ // Open slide modal for a published presentation in the user-facing viewer function openSlidesFromSlug(slug) { - showLoading('Loading slides…'); + showBusy('Loading slides...'); fetch('/api/learning/content/' + encodeURIComponent(slug) + '/slides', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; } openSlideModal(data.slides, data.css); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function previewSlides() { var md = getMarpMarkdown(); if (!md) { showToast('No slide content yet. Use AI Generate first.', 'info'); return; } - showLoading('Rendering slides…'); + showBusy('Rendering slides...'); fetch('/api/admin/learning/preview-slides', { method: 'POST', headers: getAuthHeaders(), @@ -1600,11 +1600,11 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; } openSlideModal(data.slides, data.css); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function openSlideModal(slides, css) { diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js index f713bb8..3676a5d 100644 --- a/public/js/liveEncounter.js +++ b/public/js/liveEncounter.js @@ -60,7 +60,7 @@ isRecording = true; isPaused = false; recordBtn.classList.add('recording'); - recordBtn.querySelector('span').textContent = 'Stop'; + recordBtn.style.display = 'none'; if (pauseBtn) pauseBtn.classList.remove('hidden'); if (stopBtn) stopBtn.classList.remove('hidden'); indicator.classList.remove('hidden'); @@ -74,8 +74,9 @@ isPaused = false; var dur = timer.stop(); recordBtn.classList.remove('recording'); + recordBtn.style.display = ''; recordBtn.querySelector('span').textContent = 'Start Recording'; - if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = ' Pause'; } + if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = ' Pause'; pauseBtn.classList.remove('btn-primary'); pauseBtn.classList.add('btn-ghost'); } if (stopBtn) stopBtn.classList.add('hidden'); indicator.classList.add('hidden'); if (recognition) try { recognition.stop(); } catch(e) {} @@ -88,22 +89,22 @@ if (liveText) transcript.textContent = liveText; showToast('Using browser speech recognition (no transcription API configured)', 'info'); } else { - showLoading('Transcribing...'); + showBusy('Transcribing...'); recorder.stop().then(function(blob) { - if (!blob || blob.size === 0) { hideLoading(); if (liveText) transcript.textContent = liveText; return; } + if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; } if (blob.size > 24 * 1024 * 1024) { - hideLoading(); + hideBusy(); transcript.textContent = liveText; showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); } else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Failed', 'error'); } }); - }).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast(err.message, 'error'); }); + }).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast(err.message, 'error'); }); } } }); @@ -111,7 +112,10 @@ // Dedicated stop button (always visible during recording) if (stopBtn) { stopBtn.addEventListener('click', function() { - if (isRecording) recordBtn.click(); + if (!isRecording) return; + // Temporarily show recordBtn so .click() triggers the stop flow + recordBtn.style.display = ''; + recordBtn.click(); }); } @@ -157,7 +161,7 @@ generateBtn.addEventListener('click', function() { var text = transcript.innerText.trim(); if (!text) { showToast('No transcript', 'error'); return; } - showLoading('Generating HPI...'); + showBusy('Generating HPI...'); var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve(''); @@ -177,9 +181,10 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(hpiText, data.hpi); + storeSourceContext('enc-hpi-text', transcript.innerText.trim()); if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi); modelTag.textContent = (data.model || '').split('/').pop(); outputCard.classList.remove('hidden'); @@ -187,7 +192,7 @@ showToast('HPI generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); // Refine & Shorten diff --git a/public/js/milestones.js b/public/js/milestones.js index d5a053f..341a993 100644 --- a/public/js/milestones.js +++ b/public/js/milestones.js @@ -167,7 +167,7 @@ var assessed = Object.values(state).filter(function(m) { return m.status !== null; }); if (assessed.length === 0) { showToast('Assess at least one milestone', 'error'); return; } - showLoading('Generating...'); + showBusy('Generating...'); fetch('/api/generate-milestone-narrative', { method: 'POST', @@ -183,7 +183,7 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(narrativeText, data.narrative); modelTag.textContent = (data.model || '').split('/').pop(); @@ -199,7 +199,7 @@ showToast('Generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); // 3-sentence summary diff --git a/public/js/shadess.js b/public/js/shadess.js index 61205c1..47a1225 100644 --- a/public/js/shadess.js +++ b/public/js/shadess.js @@ -280,17 +280,17 @@ indicator.classList.add('hidden'); if (_shhadessRecognition) try { _shhadessRecognition.stop(); } catch(e) {} - showLoading('Transcribing SSHADESS...'); + showBusy('Transcribing SSHADESS...'); _shadessRecorder.stop().then(function(blob) { - if (!blob || blob.size === 0) { hideLoading(); return; } + if (!blob || blob.size === 0) { hideBusy(); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { _shadessTranscript = data.text; showToast('SSHADESS audio transcribed (' + dur + 's)', 'success'); } }); - }).catch(function() { hideLoading(); }); + }).catch(function() { hideBusy(); }); } }); @@ -344,7 +344,7 @@ return; } - showLoading('Generating SSHADESS assessment...'); + showBusy('Generating SSHADESS assessment...'); var modelEl = document.querySelector('#wv-panel-shadess .tab-model-select'); @@ -361,7 +361,7 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { var out = document.getElementById('shadess-result-text'); var outCard = document.getElementById('shadess-output'); @@ -377,7 +377,7 @@ showToast(data.error || 'Generation failed', 'error'); } }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } // ── Well Visit Note — ROS data ──────────────────────────────────────────── @@ -448,9 +448,13 @@ }).join(''); }; - // Wire ROS/PE click events on a container element + // Wire ROS/PE click events on a container element (idempotent — safe to call multiple times) window.wireRosContainer = function(containerEl, dataObj) { - containerEl.addEventListener('click', function(e) { + // Remove previous handler to prevent duplicate listeners on re-render + if (containerEl._rosClickHandler) containerEl.removeEventListener('click', containerEl._rosClickHandler); + if (containerEl._rosInputHandler) containerEl.removeEventListener('input', containerEl._rosInputHandler); + + containerEl._rosClickHandler = function(e) { var btn = e.target.closest('.ros-status-btn'); if (!btn) return; var sysKey = btn.dataset.sys; @@ -481,14 +485,16 @@ } } } - }); + }; + containerEl.addEventListener('click', containerEl._rosClickHandler); - containerEl.addEventListener('input', function(e) { + containerEl._rosInputHandler = function(e) { var inp = e.target.closest('.ros-note-input'); if (!inp) return; var sysKey = inp.dataset.sys; if (sysKey) dataObj[sysKey + '_note'] = inp.value; - }); + }; + containerEl.addEventListener('input', containerEl._rosInputHandler); }; // Format ROS data as text for AI @@ -739,15 +745,15 @@ indicator.classList.add('hidden'); if (_wvRecognition) try { _wvRecognition.stop(); } catch(e) {} - showLoading('Transcribing...'); + showBusy('Transcribing...'); _wvRecorder.stop().then(function(blob) { - if (!blob || blob.size === 0) { hideLoading(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; } + if (!blob || blob.size === 0) { hideBusy(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); } }); - }).catch(function() { hideLoading(); }); + }).catch(function() { hideBusy(); }); } }); @@ -785,26 +791,51 @@ window.wireRosContainer(peContainer, _peData); } + function refreshRos() { + if (rosContainer) { + rosContainer.innerHTML = window.renderRosRows(ROS_SYSTEMS, _rosData, 'ros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }); + window.wireRosContainer(rosContainer, _rosData); + } + } + function refreshPe() { + if (peContainer) { + peContainer.innerHTML = window.renderRosRows(PE_SYSTEMS, _peData, 'pe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }); + window.wireRosContainer(peContainer, _peData); + } + } + if (allWnlBtn) { - allWnlBtn.addEventListener('click', function() { + allWnlBtn.onclick = function() { ROS_SYSTEMS.forEach(function(sys) { _rosData[sys.key + '_status'] = 'wnl'; }); - if (rosContainer) { - rosContainer.innerHTML = window.renderRosRows(ROS_SYSTEMS, _rosData, 'ros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }); - window.wireRosContainer(rosContainer, _rosData); - } + refreshRos(); showToast('All ROS set to WNL', 'success'); - }); + }; + } + + var rosClearBtn = document.getElementById('wv-ros-clear'); + if (rosClearBtn) { + rosClearBtn.onclick = function() { + ROS_SYSTEMS.forEach(function(sys) { _rosData[sys.key + '_status'] = ''; _rosData[sys.key + '_note'] = ''; }); + refreshRos(); + showToast('ROS cleared', 'info'); + }; } if (allNormalBtn) { - allNormalBtn.addEventListener('click', function() { + allNormalBtn.onclick = function() { PE_SYSTEMS.forEach(function(sys) { _peData[sys.key + '_status'] = 'wnl'; }); - if (peContainer) { - peContainer.innerHTML = window.renderRosRows(PE_SYSTEMS, _peData, 'pe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }); - window.wireRosContainer(peContainer, _peData); - } + refreshPe(); showToast('All PE set to Normal', 'success'); - }); + }; + } + + var peClearBtn = document.getElementById('wv-pe-clear'); + if (peClearBtn) { + peClearBtn.onclick = function() { + PE_SYSTEMS.forEach(function(sys) { _peData[sys.key + '_status'] = ''; _peData[sys.key + '_note'] = ''; }); + refreshPe(); + showToast('PE cleared', 'info'); + }; } if (dxContainer) { @@ -820,7 +851,7 @@ var transcriptEl = document.getElementById('wv-transcript'); var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : ''; - showLoading('Generating well visit note...'); + showBusy('Generating well visit note...'); var modelEl = document.querySelector('#wv-panel-note .tab-model-select'); @@ -856,12 +887,20 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { var noteEl = document.getElementById('wv-note-text'); var outCard = document.getElementById('wv-note-output'); var tag = document.getElementById('wv-note-model-tag'); if (noteEl) setOutputText(noteEl, data.note); + if (typeof trackAIOutput === 'function') trackAIOutput('wv-note-text', data.note); + // Store source for refine — collect all well visit inputs + var wvSource = []; + var wvTranscript = document.getElementById('wv-note-transcript'); + if (wvTranscript && wvTranscript.innerText.trim()) wvSource.push('TRANSCRIPT: ' + wvTranscript.innerText.trim()); + var wvAge = document.getElementById('wv-note-age'); + if (wvAge && wvAge.value) wvSource.push('Age: ' + wvAge.value); + storeSourceContext('wv-note-text', wvSource.join('\n')); if (tag) tag.textContent = (data.model || '').split('/').pop(); if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); } showToast('Well visit note generated!', 'success'); @@ -869,7 +908,7 @@ showToast(data.error || 'Generation failed', 'error'); } }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } // ── Wire subtab events ─────────────────────────────────────────────────── diff --git a/public/js/sickVisit.js b/public/js/sickVisit.js index 6142728..027f87d 100644 --- a/public/js/sickVisit.js +++ b/public/js/sickVisit.js @@ -177,15 +177,15 @@ if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {} document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'sick' } })); - showLoading('Transcribing...'); + showBusy('Transcribing...'); _sickRecorder.stop().then(function(blob) { - if (!blob || blob.size === 0) { hideLoading(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; } + if (!blob || blob.size === 0) { hideBusy(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); } }); - }).catch(function() { hideLoading(); }); + }).catch(function() { hideBusy(); }); } }); @@ -292,7 +292,7 @@ var peText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickPeSystems, _sickPeData, 'Physical Examination') : ''; var dxText = typeof window.formatDxForAI === 'function' ? window.formatDxForAI(_sickSelectedDx, 'sick-dx-freetext') : ''; - showLoading('Generating sick visit note...'); + showBusy('Generating sick visit note...'); var modelEl = document.getElementById('sick-model-select'); @@ -317,12 +317,15 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { var noteEl = document.getElementById('sick-note-text'); var outCard = document.getElementById('sick-note-output'); var tag = document.getElementById('sick-note-model-tag'); if (noteEl) setOutputText(noteEl, data.note); + if (typeof trackAIOutput === 'function') trackAIOutput('sick-note-text', data.note); + var sickTranscript = document.getElementById('sick-transcript'); + if (sickTranscript) storeSourceContext('sick-note-text', sickTranscript.innerText.trim()); if (tag) tag.textContent = (data.model || '').split('/').pop(); if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); } showToast('Sick visit note generated!', 'success'); @@ -330,7 +333,7 @@ showToast(data.error || 'Generation failed', 'error'); } }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } // ── Reset ───────────────────────────────────────────────────────────────── @@ -368,11 +371,21 @@ renderRosPe(); showToast('All ROS set to WNL', 'success'); } + if (e.target.closest('#sick-ros-clear')) { + _sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = ''; _sickRosData[sys.key + '_note'] = ''; }); + renderRosPe(); + showToast('ROS cleared', 'info'); + } if (e.target.closest('#sick-pe-all-normal')) { _sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = 'wnl'; }); renderRosPe(); showToast('All PE set to Normal', 'success'); } + if (e.target.closest('#sick-pe-clear')) { + _sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = ''; _sickPeData[sys.key + '_note'] = ''; }); + renderRosPe(); + showToast('PE cleared', 'info'); + } if (e.target.closest('#btn-sick-save')) { var labelEl = document.getElementById('sick-label'); diff --git a/public/js/soap.js b/public/js/soap.js index 1e7f6e5..193c60a 100644 --- a/public/js/soap.js +++ b/public/js/soap.js @@ -71,22 +71,22 @@ if (liveText) transcript.textContent = liveText; showToast('Using browser speech recognition', 'info'); } else { - showLoading('Transcribing...'); + showBusy('Transcribing...'); recorder.stop().then(function(blob) { - if (!blob) { hideLoading(); if (liveText) transcript.textContent = liveText; return; } + if (!blob) { hideBusy(); if (liveText) transcript.textContent = liveText; return; } if (blob.size > 24 * 1024 * 1024) { - hideLoading(); + hideBusy(); transcript.textContent = liveText; showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) transcript.textContent = data.text; else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); } else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); } }); - }).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); }); + }).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); }); } } }); @@ -104,7 +104,7 @@ var text = transcript.innerText.trim(); if (!text) { showToast('No input', 'error'); return; } - showLoading('Generating SOAP note...'); + showBusy('Generating SOAP note...'); var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve(''); @@ -125,9 +125,10 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(soapText, data.soap); + storeSourceContext('soap-text', transcript.innerText.trim()); if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap); modelTag.textContent = (data.model || '').split('/').pop(); outputCard.classList.remove('hidden'); @@ -135,7 +136,7 @@ showToast('SOAP note generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); }); diff --git a/public/js/voiceDictation.js b/public/js/voiceDictation.js index ac23a57..5d7203d 100644 --- a/public/js/voiceDictation.js +++ b/public/js/voiceDictation.js @@ -79,22 +79,22 @@ if (liveText) transcript.textContent = liveText; showToast('Using browser speech recognition (no transcription API configured)', 'info'); } else { - showLoading('Transcribing...'); + showBusy('Transcribing...'); recorder.stop().then(function(blob) { - if (!blob || blob.size === 0) { hideLoading(); if (liveText) transcript.textContent = liveText; return; } + if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; } if (blob.size > 24 * 1024 * 1024) { - hideLoading(); + hideBusy(); transcript.textContent = liveText; showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } return transcribeAudio(blob).then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); } else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); } }); - }).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); }); + }).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); }); } } }); @@ -145,7 +145,7 @@ var outputType = document.getElementById('dict-output-type').value; var endpoint, bodyData; - showLoading('Generating...'); + showBusy('Generating...'); var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve(''); @@ -175,9 +175,10 @@ }) .then(function(r) { return r.json(); }) .then(function(data) { - hideLoading(); + hideBusy(); if (data.success) { setOutputText(hpiText, data.hpi || data.soap); + storeSourceContext('dict-hpi-text', transcript.innerText.trim()); if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap); modelTag.textContent = (data.model || '').split('/').pop(); outputCard.classList.remove('hidden'); @@ -185,7 +186,7 @@ showToast('Generated!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) - .catch(function(err) { hideLoading(); showToast(err.message, 'error'); }); + .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); }); document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); }); diff --git a/server.js b/server.js index d623160..adbfff8 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,9 @@ const loggingMiddleware = require('./src/middleware/logging'); const app = express(); const server = http.createServer(app); +// Trust first proxy (Nginx/Caddy) — required for correct client IP in rate limiting and audit logs +app.set('trust proxy', 1); + // ============================================================ // SECURITY — Helmet with CSP // ============================================================ @@ -47,14 +50,16 @@ app.use(helmet({ // ============================================================ // CORS — restrict to APP_URL origin in production // ============================================================ -var allowedOrigin = process.env.APP_URL ? process.env.APP_URL.replace(/\/$/, '') : null; +var allowedOrigins = []; +if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, '')); +if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); }); app.use(cors({ origin: function(origin, callback) { // Allow requests with no origin (mobile apps, curl, server-to-server) if (!origin) return callback(null, true); - // In development (no APP_URL set) allow all - if (!allowedOrigin) return callback(null, true); - if (origin === allowedOrigin) return callback(null, true); + // In development (no origins configured) allow all + if (allowedOrigins.length === 0) return callback(null, true); + if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true); callback(new Error('CORS: origin not allowed')); }, credentials: true, @@ -185,6 +190,7 @@ app.use('/api', require('./src/routes/encounters')); app.use('/api', require('./src/routes/memories')); app.use('/api', require('./src/routes/documents')); app.use('/api', require('./src/routes/audioBackups')); +app.use('/api/sessions', require('./src/routes/sessions')); app.use('/api', require('./src/routes/wellVisit')); app.use('/api', require('./src/routes/sickVisit')); app.use('/api/user', require('./src/routes/userPreferences')); diff --git a/src/db/database.js b/src/db/database.js index 95d4499..2682baf 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -260,6 +260,22 @@ async function initDatabase() { CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id); `); } catch(e) {} + // User sessions for session management (revocable login sessions) + try { await client.query(` + CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + device_label TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_activity TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON user_sessions(token_hash); + `); } catch(e) {} + // Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005) try { await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)'); @@ -356,6 +372,10 @@ async function cleanupExpired() { var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()'); if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups'); } catch(e) { /* table may not exist yet */ } + try { + var sess = await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'"); + if (sess.rowCount > 0) console.log('[DB] Cleaned up ' + sess.rowCount + ' expired sessions'); + } catch(e) { /* table may not exist yet */ } } setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h) setTimeout(cleanupExpired, 10000); // also run 10s after startup diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 2d9f181..4bbfb05 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const db = require('../db/database'); +const { hashToken } = require('../utils/sessions'); const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; @@ -31,6 +32,29 @@ async function authMiddleware(req, res, next) { } req.user = user; + + // Session validation — allows revocation + try { + var tHash = hashToken(token); + var session = await db.get('SELECT id, last_activity FROM user_sessions WHERE token_hash = ?', [tHash]); + if (!session) { + // Pre-migration user? If they have no sessions at all, allow through + var sessCount = await db.get('SELECT COUNT(*) as count FROM user_sessions WHERE user_id = ?', [user.id]); + if (parseInt(sessCount.count) > 0) { + return res.status(401).json({ error: 'Session revoked' }); + } + } else { + req.sessionId = session.id; + // Throttled last_activity update — at most once per 5 minutes, fire-and-forget + var lastAct = new Date(session.last_activity).getTime(); + if (Date.now() - lastAct > 5 * 60 * 1000) { + db.run('UPDATE user_sessions SET last_activity = NOW() WHERE id = ?', [session.id]).catch(function() {}); + } + } + } catch (sessErr) { + // Fail open — table may not exist yet during migration + } + next(); } catch (err) { return res.status(401).json({ error: 'Invalid or expired token' }); diff --git a/src/routes/auth.js b/src/routes/auth.js index d9ea914..23f33d4 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -7,6 +7,31 @@ const QRCode = require('qrcode'); const crypto = require('crypto'); const db = require('../db/database'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); +const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions'); + +// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent) +async function checkPwnedPassword(password) { + try { + var sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); + var prefix = sha1.substring(0, 5); + var suffix = sha1.substring(5); + var resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, { + headers: { 'User-Agent': 'PediatricAIScribe-PasswordCheck' } + }); + if (!resp.ok) return 0; + var text = await resp.text(); + var match = text.split('\n').find(function(line) { return line.startsWith(suffix); }); + return match ? parseInt(match.split(':')[1]) : 0; + } catch (e) { return 0; } // fail open — don't block registration if HIBP is down +} + +// Public endpoint — check password before form submission +router.post('/check-password', async (req, res) => { + var { password } = req.body; + if (!password) return res.json({ breached: false }); + var count = await checkPwnedPassword(password); + res.json({ breached: count > 0, count: count }); +}); // ── Cookie helper ───────────────────────────────────────────── // Sets the JWT as a secure, httpOnly cookie so JS cannot read it @@ -197,8 +222,13 @@ router.post('/register', async (req, res) => { if (!smtpHost) { await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]); var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' }); + var regSessionId = generateSessionId(); + try { + await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', + [regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); + } catch (e) { /* table may not exist yet */ } return res.json({ - success: true, token: token, + success: true, token: token, sessionId: regSessionId, user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true }, message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).' }); @@ -295,8 +325,15 @@ router.post('/login', async (req, res) => { var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); + // Create session record + var sessionId = generateSessionId(); + try { + await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', + [sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); + } catch (e) { /* table may not exist yet */ } + res.json({ - success: true, token: token, + success: true, token: token, sessionId: sessionId, user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified } }); } catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); } @@ -370,18 +407,58 @@ router.post('/reset-password', async (req, res) => { if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' }); var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]); if (!user) return res.status(400).json({ error: 'Invalid or expired token' }); + var pwnedCount = await checkPwnedPassword(newPassword); var hash = await bcrypt.hash(newPassword, 12); await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]); - res.json({ success: true }); + // Force logout — destroy all sessions for this user + try { await db.run('DELETE FROM user_sessions WHERE user_id = ?', [user.id]); } catch (e) { /* best effort */ } + var resp = { success: true }; + if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider changing it to something unique.'; + res.json(resp); } catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); } }); -// Logout — clears the auth cookie server-side -router.post('/logout', function(req, res) { +// Logout — destroy session and clear cookie +router.post('/logout', async function(req, res) { + try { + var token = null; + var authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) token = authHeader.substring(7) || null; + if (!token && req.cookies && req.cookies.ped_auth) token = req.cookies.ped_auth; + if (token) { + await db.run('DELETE FROM user_sessions WHERE token_hash = ?', [hashToken(token)]); + } + } catch (e) { /* best effort */ } clearAuthCookie(res); res.json({ success: true }); }); +// Change password (requires current password) +router.post('/change-password', authMiddleware, async (req, res) => { + try { + var { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' }); + if (newPassword.length < 8) return res.status(400).json({ error: 'New password must be 8+ characters' }); + + var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]); + var valid = await bcrypt.compare(currentPassword, user.password); + if (!valid) return res.status(401).json({ error: 'Current password is incorrect' }); + + var pwnedCount = await checkPwnedPassword(newPassword); + var hash = await bcrypt.hash(newPassword, 12); + await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, req.user.id]); + + // Destroy all OTHER sessions (keep current one) + try { await db.run('DELETE FROM user_sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId || '']); } catch (e) { /* best effort */ } + + await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]); + + var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' }; + if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider choosing a different one.'; + res.json(resp); + } catch (err) { console.error('[Auth] Change password error:', err.message); res.status(500).json({ error: 'Password change failed' }); } +}); + // Get current user router.get('/me', authMiddleware, async (req, res) => { try { diff --git a/src/routes/chartReview.js b/src/routes/chartReview.js index c141a1a..bf3e815 100644 --- a/src/routes/chartReview.js +++ b/src/routes/chartReview.js @@ -3,6 +3,7 @@ const router = express.Router(); const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); // Generate chart review router.post('/generate-chart-review', authMiddleware, async (req, res) => { @@ -63,6 +64,7 @@ router.post('/generate-chart-review', authMiddleware, async (req, res) => { ], { model, maxTokens: 4000 }); res.json({ success: true, review: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_chart_review', 'Generated chart review', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/documents.js b/src/routes/documents.js index c4feeee..9820c7a 100644 --- a/src/routes/documents.js +++ b/src/routes/documents.js @@ -101,6 +101,7 @@ router.post('/documents/upload', upload.single('file'), async function(req, res) ); res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname }); + logger.audit(req.user.id, 'document_upload', 'Uploaded: ' + (req.file ? req.file.originalname : 'unknown'), req, { category: 'documents' }); } catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: e.message }); } }); @@ -125,6 +126,7 @@ router.get('/documents/:id/download', async function(req, res) { var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min res.json({ success: true, url: url }); + logger.audit(req.user.id, 'document_download', 'Downloaded document ' + req.params.id, req, { category: 'documents' }); } catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: e.message }); } }); @@ -150,6 +152,7 @@ router.delete('/documents/:id', async function(req, res) { await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); res.json({ success: true }); + logger.audit(req.user.id, 'document_delete', 'Deleted document ' + req.params.id, req, { category: 'documents' }); } catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: e.message }); } }); diff --git a/src/routes/encounters.js b/src/routes/encounters.js index 4d6f9f1..e4e0757 100644 --- a/src/routes/encounters.js +++ b/src/routes/encounters.js @@ -55,6 +55,7 @@ router.post('/encounters/saved', async function(req, res) { ] ); res.json({ success: true, id: id }); + logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); } else { // Enforce unique label per user (within active/non-expired encounters) if (label && label.trim()) { @@ -85,6 +86,7 @@ router.post('/encounters/saved', async function(req, res) { dup.id, req.user.id ] ); + logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); return res.json({ success: true, id: dup.id }); } } @@ -104,6 +106,7 @@ router.post('/encounters/saved', async function(req, res) { ] ); res.json({ success: true, id: result.lastInsertRowid }); + logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); } } catch (e) { logger.error('POST /encounters/saved', e.message); res.status(500).json({ error: e.message }); } }); @@ -117,6 +120,7 @@ router.delete('/encounters/saved/:id', async function(req, res) { ); if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); res.json({ success: true }); + logger.audit(req.user.id, 'encounter_delete', 'Deleted encounter ' + req.params.id, req, { category: 'clinical' }); } catch (e) { logger.error('DELETE /encounters/saved/:id', e.message); res.status(500).json({ error: e.message }); } }); diff --git a/src/routes/hospitalCourse.js b/src/routes/hospitalCourse.js index 5bbbb13..9d03380 100644 --- a/src/routes/hospitalCourse.js +++ b/src/routes/hospitalCourse.js @@ -3,6 +3,7 @@ const router = express.Router(); const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); // Generate hospital course router.post('/generate-hospital-course', authMiddleware, async (req, res) => { @@ -94,6 +95,7 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => { format: format, model: result.model }); + logger.audit(req.user.id, 'generate_hospital_course', 'Generated hospital course', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/hpi.js b/src/routes/hpi.js index 7d6847c..58af674 100644 --- a/src/routes/hpi.js +++ b/src/routes/hpi.js @@ -3,6 +3,7 @@ const router = express.Router(); const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); // HPI from encounter router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => { @@ -21,6 +22,7 @@ router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => { ], { model }); res.json({ success: true, hpi: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_hpi_encounter', 'Generated HPI from encounter', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -43,6 +45,7 @@ router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => { ], { model }); res.json({ success: true, hpi: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_hpi_dictation', 'Generated HPI from dictation', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/milestones.js b/src/routes/milestones.js index 40b85db..bdd072a 100644 --- a/src/routes/milestones.js +++ b/src/routes/milestones.js @@ -4,6 +4,7 @@ const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); const db = require('../db/database'); +var logger = require('../utils/logger'); // Get all milestones for client (authenticated users) router.get('/milestones-data', authMiddleware, async (req, res) => { @@ -68,6 +69,7 @@ router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => model: result.model, summary: { achieved: achieved.length, notAchieved: notAchieved.length, notAssessed: notAssessed.length } }); + logger.audit(req.user.id, 'generate_milestone_narrative', 'Generated milestone narrative', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -85,6 +87,7 @@ router.post('/generate-milestone-summary', authMiddleware, async (req, res) => { ], { model, maxTokens: 500 }); res.json({ success: true, summary: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_milestone_summary', 'Generated milestone summary', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/nextcloud.js b/src/routes/nextcloud.js index 44c0fc1..f90c6a9 100644 --- a/src/routes/nextcloud.js +++ b/src/routes/nextcloud.js @@ -3,6 +3,7 @@ var router = express.Router(); var axios = require('axios'); var { authMiddleware } = require('../middleware/auth'); var db = require('../db/database'); +var logger = require('../utils/logger'); router.post('/nextcloud/connect', authMiddleware, async function(req, res) { try { @@ -29,6 +30,7 @@ router.post('/nextcloud/connect', authMiddleware, async function(req, res) { [cleanUrl, username, appPassword, targetFolder, req.user.id]); res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' }); + logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud', req, { category: 'integration' }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -56,6 +58,7 @@ router.post('/nextcloud/export', authMiddleware, async function(req, res) { await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: user.nextcloud_token }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' }); + logger.audit(req.user.id, 'nextcloud_export', 'Exported note to Nextcloud', req, { category: 'export' }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -63,6 +66,7 @@ router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) { try { await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]); res.json({ success: true }); + logger.audit(req.user.id, 'nextcloud_disconnect', 'Disconnected Nextcloud', req, { category: 'integration' }); } catch (err) { res.status(500).json({ error: err.message }); } }); diff --git a/src/routes/oidc.js b/src/routes/oidc.js index 16adf7b..aadb015 100644 --- a/src/routes/oidc.js +++ b/src/routes/oidc.js @@ -9,6 +9,7 @@ var crypto = require('crypto'); var jwt = require('jsonwebtoken'); var db = require('../db/database'); var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth'); +var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions'); // In-memory OIDC state store (short-lived, no DB needed) var pendingStates = {}; @@ -176,11 +177,18 @@ router.get('/oidc/callback', async function(req, res) { var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); setAuthCookie(res, token); + // Create session record + var ssoSessionId = generateSessionId(); + try { + await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', + [ssoSessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); + } catch (e) { /* table may not exist yet */ } + await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', [user.id, 'login_oidc', req.ip, 'SSO via ' + issuer]); - // Redirect to app — token is in httpOnly cookie, pass minimal flag - res.redirect(appUrl + '?sso=ok'); + // Redirect to app — token is in httpOnly cookie, pass session ID + res.redirect(appUrl + '?sso=ok&sid=' + ssoSessionId); } catch (err) { console.error('[OIDC] Callback error:', err.message); res.redirect(appUrl + '?error=sso_failed'); diff --git a/src/routes/refine.js b/src/routes/refine.js index 624540e..1732181 100644 --- a/src/routes/refine.js +++ b/src/routes/refine.js @@ -7,23 +7,32 @@ var router = express.Router(); var { callAI } = require('../utils/ai'); var PROMPTS = require('../utils/prompts'); var { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); // Refine/modify document with instructions router.post('/refine', authMiddleware, async function(req, res) { try { var currentDocument = req.body.currentDocument; var instructions = req.body.instructions; + var sourceContext = req.body.sourceContext; var model = req.body.model; if (!currentDocument) return res.status(400).json({ error: 'No document to refine' }); if (!instructions) return res.status(400).json({ error: 'No instructions provided' }); + var userContent = ''; + if (sourceContext) { + userContent += 'ORIGINAL SOURCE MATERIAL (reference this for any data lookups — labs, notes, history):\n' + sourceContext + '\n\n'; + } + userContent += 'CURRENT DOCUMENT (modify this based on user instructions):\n' + currentDocument + '\n\nUSER INSTRUCTIONS:\n' + instructions; + var result = await callAI([ { role: 'system', content: PROMPTS.refine }, - { role: 'user', content: 'CURRENT DOCUMENT:\n' + currentDocument + '\n\nUSER INSTRUCTIONS:\n' + instructions } + { role: 'user', content: userContent } ], { model: model, maxTokens: 6000 }); res.json({ success: true, refined: result.content, model: result.model }); + logger.audit(req.user.id, 'refine_document', 'Refined document', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -43,6 +52,7 @@ router.post('/shorten', authMiddleware, async function(req, res) { ], { model: model }); res.json({ success: true, shortened: result.content, model: result.model }); + logger.audit(req.user.id, 'shorten_document', 'Shortened document', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -63,6 +73,7 @@ router.post('/clarify', authMiddleware, async function(req, res) { ], { model: model, maxTokens: 1000 }); res.json({ success: true, questions: result.content, model: result.model }); + logger.audit(req.user.id, 'clarify_document', 'Generated clarification questions', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/sessions.js b/src/routes/sessions.js new file mode 100644 index 0000000..98c1af6 --- /dev/null +++ b/src/routes/sessions.js @@ -0,0 +1,51 @@ +var express = require('express'); +var router = express.Router(); +var db = require('../db/database'); +var { authMiddleware } = require('../middleware/auth'); + +router.use(authMiddleware); + +// List all sessions for current user +router.get('/', async function(req, res) { + try { + var sessions = await db.all( + 'SELECT id, ip_address, device_label, created_at, last_activity FROM user_sessions WHERE user_id = ? ORDER BY last_activity DESC', + [req.user.id] + ); + res.json({ success: true, sessions: sessions, currentSessionId: req.sessionId || null }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Revoke a specific session +router.delete('/:id', async function(req, res) { + try { + if (req.params.id === req.sessionId) { + return res.status(400).json({ error: 'Cannot revoke your current session. Use logout instead.' }); + } + var result = await db.run( + 'DELETE FROM user_sessions WHERE id = ? AND user_id = ?', + [req.params.id, req.user.id] + ); + if (result.changes === 0) return res.status(404).json({ error: 'Session not found' }); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Revoke all other sessions +router.delete('/', async function(req, res) { + try { + var result = await db.run( + 'DELETE FROM user_sessions WHERE user_id = ? AND id != ?', + [req.user.id, req.sessionId || ''] + ); + res.json({ success: true, revoked: result.changes }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/src/routes/sickVisit.js b/src/routes/sickVisit.js index 8d98138..1bf3d13 100644 --- a/src/routes/sickVisit.js +++ b/src/routes/sickVisit.js @@ -64,6 +64,7 @@ router.post('/sick-visit/note', authMiddleware, async function(req, res) { }); res.json({ success: true, note: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_sick_visit', 'Generated sick visit note', req, { category: 'clinical' }); } catch (e) { logger.error('[SickVisit] Note generation failed', e.message); res.status(500).json({ error: e.message }); diff --git a/src/routes/soap.js b/src/routes/soap.js index 31c8a67..1263aac 100644 --- a/src/routes/soap.js +++ b/src/routes/soap.js @@ -3,6 +3,7 @@ const router = express.Router(); const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); router.post('/generate-soap', authMiddleware, async (req, res) => { try { @@ -29,6 +30,7 @@ router.post('/generate-soap', authMiddleware, async (req, res) => { ], { model }); res.json({ success: true, soap: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_soap', 'Generated SOAP note', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index 9cd222f..7ad35d6 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -6,6 +6,7 @@ const { transcribeWithAWS, isAWSTranscribeConfigured } = require('../utils/trans const { transcribeWithLocal, isLocalWhisperConfigured } = require('../utils/transcribeLocal'); const { transcribeWithGemini, isGoogleSTTConfigured } = require('../utils/transcribeGoogle'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); @@ -68,12 +69,14 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var model = userModel || adminSttModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash'; var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model); console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms'); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via google', req, { category: 'clinical' }); return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime }); } if (provider === 'local') { var text = await transcribeWithLocal(req.file.buffer, req.file.mimetype || 'audio/webm'); console.log('[Transcribe] Local done in ' + (Date.now() - startTime) + 'ms'); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via local', req, { category: 'clinical' }); return res.json({ success: true, text: text, provider: 'local-whisper', duration: Date.now() - startTime }); } @@ -81,19 +84,41 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r if (!isAWSTranscribeConfigured()) return res.status(400).json({ error: 'AWS Transcribe not configured.' }); var text = await transcribeWithAWS(req.file.buffer, req.file.mimetype || 'audio/webm'); console.log('[Transcribe] AWS done in ' + (Date.now() - startTime) + 'ms'); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via aws', req, { category: 'clinical' }); return res.json({ success: true, text: text, provider: 'aws-transcribe', duration: Date.now() - startTime }); } if (provider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' }); - // LiteLLM /audio/transcriptions does NOT support Vertex AI Chirp (Unmapped provider error). - // Instead, use /v1/chat/completions with a Gemini model — Gemini understands audio natively - // via base64 inline data. Set LITELLM_STT_MODEL to your Gemini model name in LiteLLM. var sttModel = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; var axios = require('axios'); - var base64audio = req.file.buffer.toString('base64'); - var mimeType = req.file.mimetype || 'audio/webm'; var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + var mimeType = req.file.mimetype || 'audio/webm'; + + // Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.) + var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); + + if (isTranscriptionModel) { + // Use /v1/audio/transcriptions endpoint (OpenAI-compatible) + var ext = mimeType.split('/')[1] || 'webm'; + var file = new File([req.file.buffer], 'audio.' + ext, { type: mimeType }); + var form = new FormData(); + form.append('file', file); + form.append('model', sttModel); + var fetchHeaders = {}; + if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; + + var sttResp = await fetch(base + '/v1/audio/transcriptions', { + method: 'POST', headers: fetchHeaders, body: form + }).then(function(r) { return r.json().then(function(d) { return { data: d }; }); }); + var text = sttResp.data && sttResp.data.text ? sttResp.data.text : ''; + console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms'); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' }); + return res.json({ success: true, text: text.trim(), provider: 'litellm/' + sttModel, duration: Date.now() - startTime }); + } + + // Gemini-style models — use /v1/chat/completions with inline audio + var base64audio = req.file.buffer.toString('base64'); var headers = { 'Content-Type': 'application/json' }; if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; @@ -120,7 +145,8 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r text = (msg && msg.content) ? msg.content : ''; } console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms'); - return res.json({ success: true, text: text.trim(), provider: 'litellm-gemini', duration: Date.now() - startTime }); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' }); + return res.json({ success: true, text: text.trim(), provider: 'litellm/' + sttModel, duration: Date.now() - startTime }); } // OpenAI Whisper (direct) @@ -133,6 +159,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r }); var text = typeof result === 'string' ? result : result.text; console.log('[Transcribe] Whisper done in ' + (Date.now() - startTime) + 'ms'); + logger.audit(req.user.id, 'transcribe', 'Transcribed audio via openai', req, { category: 'clinical' }); res.json({ success: true, text: text, provider: 'openai-whisper', duration: Date.now() - startTime }); } catch (err) { diff --git a/src/routes/tts.js b/src/routes/tts.js index 7473625..d3f76ea 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -3,6 +3,7 @@ const router = express.Router(); const axios = require('axios'); const { synthesizeWithGoogleTTS, isGoogleTTSConfigured } = require('../utils/ttsGoogle'); const { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); // Provider priority (auto-detect): // TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible) @@ -43,6 +44,7 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { var buffer = await synthesizeWithGoogleTTS(text, voice); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'google-tts/' + voice); + logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(buffer); } @@ -63,6 +65,7 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { ); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'litellm/' + ttsModel); + logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(Buffer.from(ttsResp.data)); } @@ -77,6 +80,7 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { }); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'elevenlabs'); + logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(Buffer.from(resp.data)); } diff --git a/src/routes/wellVisit.js b/src/routes/wellVisit.js index 96ee95c..f0622b3 100644 --- a/src/routes/wellVisit.js +++ b/src/routes/wellVisit.js @@ -123,6 +123,7 @@ router.post('/well-visit/shadess', authMiddleware, async function(req, res) { }); res.json({ success: true, assessment: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_shadess', 'Generated SSHADESS assessment', req, { category: 'clinical' }); } catch (e) { logger.error('[WellVisit] SHADESS generation failed', e.message); res.status(500).json({ error: e.message }); @@ -224,6 +225,7 @@ router.post('/well-visit/note', authMiddleware, async function(req, res) { }); res.json({ success: true, note: result.content, model: result.model }); + logger.audit(req.user.id, 'generate_well_visit', 'Generated well visit note', req, { category: 'clinical' }); } catch (e) { logger.error('[WellVisit] Note generation failed', e.message); res.status(500).json({ error: e.message }); diff --git a/src/utils/sessions.js b/src/utils/sessions.js new file mode 100644 index 0000000..fd60c27 --- /dev/null +++ b/src/utils/sessions.js @@ -0,0 +1,32 @@ +var crypto = require('crypto'); + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function parseUserAgent(ua) { + if (!ua) return 'Unknown device'; + var browser = 'Unknown browser'; + var os = 'Unknown OS'; + + if (/Edg\//.test(ua)) browser = 'Edge'; + else if (/OPR\/|Opera/.test(ua)) browser = 'Opera'; + else if (/Firefox\//.test(ua)) browser = 'Firefox'; + else if (/Safari\//.test(ua) && !/Chrome/.test(ua)) browser = 'Safari'; + else if (/Chrome\//.test(ua)) browser = 'Chrome'; + + if (/Windows/.test(ua)) os = 'Windows'; + else if (/Mac OS X/.test(ua)) os = 'macOS'; + else if (/Android/.test(ua)) os = 'Android'; + else if (/iPhone|iPad/.test(ua)) os = 'iOS'; + else if (/Linux/.test(ua)) os = 'Linux'; + else if (/CrOS/.test(ua)) os = 'ChromeOS'; + + return browser + ' on ' + os; +} + +function generateSessionId() { + return crypto.randomUUID(); +} + +module.exports = { hashToken, parseUserAgent, generateSessionId };