v6.2: Session management, password change, audit logging, refine context, UI fixes
Security: - Add session management: users can view/revoke active sessions in Settings - Add password change in Settings (requires current password, HIBP check) - Force logout all sessions on password reset - Fix logout to destroy server-side session (was only clearing cookie) - Add trust proxy for correct client IP in rate limiting and audit logs - Add CORS support for multiple domains (CORS_ORIGINS env var) - Add HIBP breach check endpoint and inline warnings on password fields Audit logging: - Add audit logging to all 24 PHI-handling endpoints across 13 route files - Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud - All fire-and-forget (no response delay) AI improvements: - Refine now includes original source material (transcript, notes, labs) so AI can reference the full input when modifying output - Add correction tracking (trackAIOutput) to sick visit and well visit tabs - Fix sickvisit missing from encounter save noteIdMap UI fixes: - Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay) - Fix encounter recording: hide record button during recording (was showing two stop buttons) - Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons - Enlarge AI instructions textarea in Learning Hub CMS Domain: - Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
This commit is contained in:
parent
33b47d4cb1
commit
d12d4f4e63
38 changed files with 737 additions and 120 deletions
|
|
@ -309,6 +309,39 @@ Audio is stored as gzip-compressed BYTEA in PostgreSQL because:
|
|||
|
||||
The physician memory/correction system injects past edits into AI prompts. Originally these were labeled "APPLY these preferences" which caused smaller models to hallucinate content from the correction examples instead of the current transcript. The injection was changed to `[STYLE HINTS (low priority)]` with truncated 200-char snippets to prevent this.
|
||||
|
||||
---
|
||||
|
||||
## AI Learning System (Correction Tracker)
|
||||
|
||||
The app learns from physician edits over time, similar to Dragon Medical's adaptive learning. Here is how it works:
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Track**: When AI generates a note, `trackAIOutput(elementId, originalText)` stores the original AI output in memory (`correctionTracker.js`).
|
||||
2. **Edit**: The physician edits the generated note directly in the contenteditable output area.
|
||||
3. **Save**: When the physician clicks Save, `saveCorrection(elementId, section)` compares the current text against the stored original.
|
||||
4. **Store**: If there is a meaningful difference (more than 2 words or 20 characters changed), the before/after diff is sent to `POST /api/memories/correction` and stored in the `user_memories` table with category `correction_{section}`.
|
||||
5. **Apply**: On future generations, the last 10 corrections per category are fetched via `GET /api/memories/context` and injected into the AI prompt as low-priority style hints.
|
||||
|
||||
### Which tabs support it
|
||||
|
||||
| Tab | trackAIOutput | saveCorrection (on Save) |
|
||||
|-----|---------------|--------------------------|
|
||||
| Live Encounter | Yes (`enc-hpi-text`) | Yes |
|
||||
| SOAP | Yes (`soap-text`) | Yes |
|
||||
| Dictation | Yes (`dict-hpi-text`) | Yes |
|
||||
| Sick Visit | Yes (`sick-note-text`) | Yes |
|
||||
| Well Visit | Yes (`wv-note-text`) | Yes |
|
||||
| Hospital Course | No (output varies by format) | Yes (if tracked) |
|
||||
| Chart Review | No (output varies by input) | Yes (if tracked) |
|
||||
|
||||
### Important notes
|
||||
|
||||
- Corrections are only captured when the user clicks **Save**. Editing without saving does not trigger learning.
|
||||
- The system keeps a maximum of 20 corrections per category, auto-deleting the oldest.
|
||||
- Corrections are injected as `[STYLE HINTS (low priority)]` with 200-character snippets to avoid confusing smaller AI models.
|
||||
- Users can view and delete their corrections in Settings > AI Corrections.
|
||||
|
||||
### Why Auth Middleware Checks Cookie After Bearer
|
||||
|
||||
The auth middleware first checks the `Authorization: Bearer` header, then falls back to the `ped_auth` cookie. If a Bearer header is present but the token is empty (which happens with SSO-only users who have no localStorage token), the middleware now correctly treats it as absent and falls through to the cookie. This was a bug fix -- previously, an empty Bearer token would block cookie auth entirely.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,26 @@
|
|||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-key"></i> Change Password</h3>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="pw-current" placeholder="Enter current password">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>New Password (8+ characters)</label>
|
||||
<input type="password" id="pw-new" minlength="8" placeholder="Enter new password">
|
||||
<div id="pw-new-warning" class="pw-breach-warning" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw-confirm" minlength="8" placeholder="Confirm new password">
|
||||
</div>
|
||||
<button id="btn-change-password" class="btn-sm btn-primary"><i class="fas fa-check"></i> Change Password</button>
|
||||
<span id="pw-change-status" style="font-size:13px;margin-left:8px;"></span>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||
|
|
@ -103,6 +123,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-desktop"></i> Active Sessions</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Devices where you are currently logged in. Revoke any session to immediately log that device out.</p>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button id="btn-revoke-all-sessions" class="btn-sm btn-ghost" style="color:var(--red);font-size:12px;"><i class="fas fa-ban"></i> Revoke All Other Sessions</button>
|
||||
</div>
|
||||
<div id="sessions-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading sessions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
<h3><i class="fas fa-list-check"></i> Review of Systems</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
<button id="sick-ros-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
<select id="sick-ros-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
|
|
@ -70,6 +71,7 @@
|
|||
<h3><i class="fas fa-stethoscope"></i> Physical Examination</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<button id="sick-pe-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
<select id="sick-pe-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@
|
|||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-list-check"></i> Review of Systems (ROS)</h3>
|
||||
<button id="wv-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
<button id="wv-ros-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div id="wv-ros-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
|
@ -272,6 +273,7 @@
|
|||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-stethoscope"></i> Physical Examination (PE)</h3>
|
||||
<button id="wv-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<button id="wv-pe-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div id="wv-pe-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.form-group label{display:block;font-size:13px;font-weight:600;color:var(--g600);margin-bottom:4px;}
|
||||
.form-group input{width:100%;padding:10px 14px;border:1.5px solid var(--g300);border-radius:8px;font-size:14px;font-family:inherit;}
|
||||
.form-group input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
.pw-breach-warning{margin-top:6px;padding:8px 10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px;font-size:12px;color:#92400e;line-height:1.4;}
|
||||
.pw-breach-warning i{margin-right:4px;color:#f59e0b;}
|
||||
.form-group small{color:var(--g500);font-size:12px;}
|
||||
.btn-auth{width:100%;padding:12px;background:var(--blue);color:white;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background 0.2s;}
|
||||
.btn-auth:hover{background:var(--blue-dark);}
|
||||
|
|
@ -236,6 +238,15 @@ textarea.full-input{resize:vertical;}
|
|||
@keyframes spin{to{transform:rotate(360deg);}}
|
||||
.loading-box p{color:var(--g600);font-weight:500;font-size:13px;}
|
||||
|
||||
/* Non-blocking inline busy bar */
|
||||
.busy-bar{position:sticky;top:0;z-index:100;display:none;align-items:center;gap:8px;padding:8px 16px;background:linear-gradient(135deg,#eff6ff,#e0f2fe);border-bottom:1px solid #bfdbfe;font-size:13px;font-weight:500;color:#1d4ed8;animation:busySlideIn 0.2s ease;}
|
||||
.busy-bar.active{display:flex;}
|
||||
.busy-bar .busy-spinner{width:16px;height:16px;border:2.5px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:spin 0.7s linear infinite;flex-shrink:0;}
|
||||
.busy-bar .busy-text{flex:1;}
|
||||
.busy-bar .busy-cancel{background:none;border:1px solid #93c5fd;border-radius:4px;padding:3px 10px;font-size:11px;color:#1d4ed8;cursor:pointer;font-weight:600;}
|
||||
.busy-bar .busy-cancel:hover{background:#dbeafe;}
|
||||
@keyframes busySlideIn{from{opacity:0;transform:translateY(-100%);} to{opacity:1;transform:translateY(0);}}
|
||||
|
||||
/* TOAST */
|
||||
.toast-container{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:6px;z-index:10000;}
|
||||
.toast{padding:8px 16px;border-radius:8px;color:white;font-size:12px;font-weight:500;box-shadow:var(--shadow-md);animation:slideIn 0.3s ease;display:flex;align-items:center;gap:6px;}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="login-password" required placeholder="••••••••">
|
||||
<div id="login-pw-warning" class="pw-breach-warning" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="totp-group" class="form-group hidden">
|
||||
<label>2FA Code</label>
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
<div class="form-group">
|
||||
<label>Password (8+ characters)</label>
|
||||
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
||||
<div id="reg-pw-warning" class="pw-breach-warning" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||
<button type="submit" class="btn-auth">Create Account</button>
|
||||
|
|
@ -244,6 +246,10 @@
|
|||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div id="busy-bar" class="busy-bar">
|
||||
<div class="busy-spinner"></div>
|
||||
<span class="busy-text" id="busy-text">Processing...</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Tab components (lazy-loaded from /components/*.html) -->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = '<i class="fas fa-triangle-exclamation"></i> 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 = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
|
||||
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 '<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--g50);border-radius:8px;border:1.5px solid ' + (isCurrent ? 'var(--blue)' : 'var(--g200)') + ';">'
|
||||
+ '<div>'
|
||||
+ '<div style="font-size:13px;font-weight:600;color:var(--g800);">' + escH(s.device_label || 'Unknown device') + (isCurrent ? ' <span style="font-size:11px;color:var(--blue);font-weight:500;">(this device)</span>' : '') + '</div>'
|
||||
+ '<div style="font-size:12px;color:var(--g500);margin-top:2px;">' + escH(s.ip_address || '') + ' · Created ' + created + ' · Active ' + lastAct + '</div>'
|
||||
+ '</div>'
|
||||
+ (isCurrent ? '' : '<button class="btn-sm btn-ghost session-revoke-btn" data-sid="' + s.id + '" style="color:var(--red);font-size:12px;"><i class="fas fa-xmark"></i> Revoke</button>')
|
||||
+ '</div>';
|
||||
}).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 = '<p style="color:var(--g400);font-size:13px;">Could not load sessions.</p>';
|
||||
});
|
||||
};
|
||||
|
||||
// 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() })
|
||||
|
|
|
|||
|
|
@ -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'); });
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -65,18 +65,18 @@
|
|||
btn.innerHTML = '<i class="fas fa-microphone"></i> 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
|
||||
|
|
|
|||
|
|
@ -838,7 +838,7 @@
|
|||
|
||||
var btn = document.getElementById('btn-lh-ai-generate');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-wand-magic-sparkles"></i> 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 = '<i class="fas fa-wand-magic-sparkles"></i> 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) {
|
||||
|
|
|
|||
|
|
@ -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 = '<i class="fas fa-pause"></i> Pause'; }
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'); });
|
||||
|
|
|
|||
|
|
@ -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'); });
|
||||
|
|
|
|||
14
server.js
14
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'));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
51
src/routes/sessions.js
Normal file
51
src/routes/sessions.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
32
src/utils/sessions.js
Normal file
32
src/utils/sessions.js
Normal file
|
|
@ -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 };
|
||||
Loading…
Reference in a new issue