diff --git a/migrations/1777003849000_add-personal-notes.js b/migrations/1777003849000_add-personal-notes.js new file mode 100644 index 0000000..94f91e8 --- /dev/null +++ b/migrations/1777003849000_add-personal-notes.js @@ -0,0 +1,25 @@ +/** + * Personal Notes — lightweight per-user scratchpad living under + * Clinical Tools. Distinct from user_memories (which feed AI + * prompts as style hints / templates): personal_notes are pure + * clinician notes, never injected into an AI call. Title + rich- + * text body, encrypted at rest like memories so row dumps are + * useless without the app crypto key. + */ + +exports.up = (pgm) => { + pgm.createTable('personal_notes', { + id: { type: 'serial', primaryKey: true }, + user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' }, + title: { type: 'text', notNull: true }, + body: { type: 'text', notNull: true, default: '' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + }); + pgm.createIndex('personal_notes', 'user_id'); + pgm.createIndex('personal_notes', ['user_id', 'updated_at']); +}; + +exports.down = (pgm) => { + pgm.dropTable('personal_notes'); +}; diff --git a/public/components/notes.html b/public/components/notes.html new file mode 100644 index 0000000..e137c58 --- /dev/null +++ b/public/components/notes.html @@ -0,0 +1,87 @@ +
+

My Notes

+

A quiet place for your thoughts — jot ideas, paste references, or dictate and let AI tidy it up. Only you can see these.

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

Hello 👋

+

Pick a note on the left, or start a fresh one. You can type, paste, or tap Dictate to let AI clean up your voice into a polished note.

+ +
+
Press Ctrl+S to save
+
Dictate works even offline with Browser Whisper
+
Encrypted at rest — only you can read them
+
+
+
+ +
diff --git a/public/css/styles.css b/public/css/styles.css index 2dffdca..5eae5ea 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -853,3 +853,100 @@ textarea.full-input{resize:vertical;} .lh-webdav-dir{color:var(--g700);font-weight:500;} .lh-webdav-file{color:var(--g600);} .lh-webdav-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + +/* ── Notes tab — personal scratchpad (under Clinical Tools) ───── */ +.notes-layout{display:grid;grid-template-columns:320px 1fr;gap:14px;align-items:flex-start;min-height:70vh;} +@media (max-width:900px){.notes-layout{grid-template-columns:1fr;}} + +.notes-sidebar{background:white;border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:80vh;} +.notes-sidebar-head{padding:10px;border-bottom:1px solid var(--g200);background:var(--g50);display:flex;flex-direction:column;gap:8px;} +.notes-new-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 12px;font-size:13px;font-weight:600;} +.notes-search{position:relative;} +.notes-search i{position:absolute;top:50%;left:10px;transform:translateY(-50%);color:var(--g400);font-size:12px;pointer-events:none;} +.notes-search input{width:100%;padding:7px 10px 7px 30px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;box-sizing:border-box;background:white;} +.notes-search input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);} + +.notes-list{flex:1;overflow-y:auto;padding:4px;} +.notes-empty{padding:24px 14px;text-align:center;color:var(--g400);font-size:13px;line-height:1.5;} +.notes-list-item{display:block;width:100%;text-align:left;padding:10px 12px;border:none;background:transparent;border-radius:8px;cursor:pointer;margin-bottom:2px;font-family:inherit;transition:background 0.1s;border-left:3px solid transparent;} +.notes-list-item:hover{background:var(--g50);} +.notes-list-item.active{background:var(--blue-light);border-left-color:var(--blue);} +.notes-list-title{font-size:13.5px;font-weight:600;color:var(--g800);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:2px;} +.notes-list-snippet{font-size:12px;color:var(--g500);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.35;} +.notes-list-when{font-size:11px;color:var(--g400);margin-top:4px;} + +.notes-editor{background:white;border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;min-height:70vh;} +.notes-editor.hidden{display:none;} +.notes-editor-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--g200);background:var(--g50);border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);flex-wrap:wrap;} +.notes-title-input{flex:1;min-width:180px;padding:8px 10px;border:1px solid transparent;border-radius:6px;font-size:16px;font-weight:600;color:var(--g900);font-family:inherit;background:transparent;} +.notes-title-input:focus{outline:none;background:white;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);} +.notes-editor-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;} +.notes-status{font-size:12px;color:var(--g500);min-width:60px;text-align:right;} +.notes-status-dirty{color:var(--amber);} +.notes-status-saving{color:var(--g500);} +.notes-status-ok{color:#059669;} +.notes-status-err{color:var(--red);} + +.notes-body-editor{flex:1;display:flex;flex-direction:column;min-height:280px;} +.notes-body-editor .tp-toolbar{position:sticky;top:0;z-index:5;} +.notes-body-editor .tp-content{flex:1;padding:14px 18px;font-size:14px;line-height:1.6;color:var(--g800);overflow-y:auto;min-height:220px;} +.notes-body-editor .tp-content:focus-within .ProseMirror{outline:none;} +.notes-body-editor .ProseMirror{outline:none;min-height:200px;} +.notes-body-editor .ProseMirror p{margin:0 0 8px;} +.notes-body-editor .ProseMirror h2{font-size:18px;font-weight:700;color:var(--g900);margin:16px 0 8px;} +.notes-body-editor .ProseMirror h3{font-size:15px;font-weight:700;color:var(--g800);margin:12px 0 6px;} +.notes-body-editor .ProseMirror ul,.notes-body-editor .ProseMirror ol{margin:0 0 8px 24px;} +.notes-body-editor .ProseMirror blockquote{border-left:3px solid var(--g300);margin:0 0 8px;padding:2px 0 2px 12px;color:var(--g600);font-style:italic;} +.notes-body-editor .ProseMirror pre{background:var(--g900);color:#e5e7eb;padding:10px 14px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow-x:auto;margin:0 0 8px;} +.notes-body-editor .ProseMirror code{background:var(--g100);padding:1px 5px;border-radius:4px;font-size:12.5px;} +.notes-body-editor .ProseMirror a{color:var(--blue);text-decoration:underline;} +.notes-body-editor .notes-body-fallback{width:100%;height:100%;min-height:260px;padding:14px 18px;border:none;font-size:14px;line-height:1.6;font-family:inherit;color:var(--g800);resize:vertical;box-sizing:border-box;} + +.notes-editor-foot{padding:8px 14px;border-top:1px solid var(--g200);font-size:11px;color:var(--g400);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;} +.notes-meta{font-size:11px;color:var(--g400);} + +.notes-empty-state{background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:60px 24px;text-align:center;color:var(--g400);display:flex;flex-direction:column;align-items:center;gap:14px;min-height:70vh;justify-content:center;} +.notes-empty-state i{font-size:64px;color:var(--g300);} +.notes-empty-state p{font-size:14px;line-height:1.5;max-width:320px;margin:0;} +.notes-empty-state.hidden{display:none;} + +/* Voice-to-note recording bar inside the editor head */ +.notes-voice-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:0;} +.notes-voice-bar .btn-sm{font-size:12px;padding:6px 10px;} +.notes-rec-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--red);font-weight:600;} +.notes-rec-indicator .pulse-dot{width:8px;height:8px;background:var(--red);border-radius:50%;animation:notes-pulse 1.2s ease-in-out infinite;} +.notes-rec-indicator .pulse-dot.paused{animation:none;opacity:0.5;} +@keyframes notes-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.35;transform:scale(1.25);}} +.notes-rec-timer{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--g600);} + +/* Notes — friendlier touches */ +.notes-empty-state .notes-empty-card{max-width:440px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;} +.notes-empty-icon{width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,#fef3c7,#fde68a);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(245,158,11,0.25);} +.notes-empty-icon i{font-size:30px;color:#b45309;} +.notes-empty-state h3{margin:4px 0 0;font-size:20px;font-weight:600;color:var(--g800);} +.notes-empty-state p{font-size:14px;line-height:1.55;color:var(--g500);margin:0;} +.notes-empty-cta{margin-top:4px;padding:10px 20px;border-radius:10px;font-weight:600;display:inline-flex;align-items:center;gap:8px;box-shadow:0 2px 6px rgba(37,99,235,0.25);} +.notes-empty-cta:hover{box-shadow:0 4px 10px rgba(37,99,235,0.35);} +.notes-empty-tips{margin-top:16px;display:flex;flex-direction:column;gap:8px;font-size:12.5px;color:var(--g500);text-align:left;align-self:stretch;padding:14px 18px;background:var(--g50);border-radius:10px;} +.notes-empty-tips div{display:flex;align-items:center;gap:10px;} +.notes-empty-tips i{color:var(--blue);font-size:13px;width:16px;text-align:center;} +.notes-empty-tips kbd{background:white;border:1px solid var(--g300);border-radius:4px;padding:1px 6px;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-shadow:0 1px 0 var(--g200);} + +.notes-list-item{position:relative;transition:background 0.15s, transform 0.15s;} +.notes-list-item:hover{transform:translateX(1px);} +.notes-list-item.active{box-shadow:inset 0 0 0 1px var(--blue-light);} + +.notes-sidebar-head{padding:12px;} +.notes-new-btn{border-radius:10px;padding:9px 14px;font-size:13.5px;box-shadow:0 1px 3px rgba(37,99,235,0.2);} +.notes-new-btn:hover{box-shadow:0 2px 6px rgba(37,99,235,0.3);} + +.notes-title-input{font-size:18px;letter-spacing:-0.01em;} +.notes-title-input::placeholder{color:var(--g400);font-weight:500;} + +.notes-editor-head{gap:12px;padding:12px 16px;} +.notes-body-editor{border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);} +.notes-body-editor .tp-toolbar{border-bottom:1px solid var(--g100);background:#fdfdfd;} + +/* subtle card float on hover for the whole editor */ +.notes-editor{transition:box-shadow 0.2s;} +.notes-editor:hover{box-shadow:0 4px 16px rgba(0,0,0,0.06);} diff --git a/public/index.html b/public/index.html index 718db31..f263044 100644 --- a/public/index.html +++ b/public/index.html @@ -248,6 +248,10 @@ Pagers & Extensions + '; + }).join(''); + } + + function openEditor(id) { + if (_dirty && !confirmDiscard()) return; + _activeId = id; + _dirty = false; + + var editor = document.getElementById('notes-editor'); + var empty = document.getElementById('notes-empty-state'); + var titleEl = document.getElementById('note-title'); + var metaEl = document.getElementById('note-meta'); + var delBtn = document.getElementById('btn-note-delete'); + var bodyEl = document.getElementById('note-body-editor'); + + editor.classList.remove('hidden'); + if (empty) empty.classList.add('hidden'); + + // Tear down and rebuild the Tiptap editor each time — cheapest way + // to load a different note's HTML without drifting state. + if (_editor) { _editor.destroy(); _editor = null; } + bodyEl.innerHTML = ''; + + if (id == null) { + titleEl.value = ''; + metaEl.textContent = 'New note'; + delBtn.style.display = 'none'; + mountTiptap(bodyEl, ''); + titleEl.focus(); + } else { + var note = _notes.find(function(n) { return n.id === id; }); + if (!note) return; + titleEl.value = note.title || ''; + metaEl.textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at); + delBtn.style.display = ''; + mountTiptap(bodyEl, note.body || ''); + } + updateStatus('', ''); + setRecUI('idle'); + renderList(); // refresh active-row highlight + } + + function closeEditor() { + if (_dirty && !confirmDiscard()) return; + _activeId = null; + _dirty = false; + if (_editor) { _editor.destroy(); _editor = null; } + var editor = document.getElementById('notes-editor'); + var empty = document.getElementById('notes-empty-state'); + if (editor) editor.classList.add('hidden'); + if (empty) empty.classList.remove('hidden'); + renderList(); + } + + function saveNote() { + var titleEl = document.getElementById('note-title'); + var title = (titleEl.value || '').trim(); + if (!title) { updateStatus('Title required', 'err'); titleEl.focus(); return; } + var body = _editor ? _editor.getHTML() : ''; + if (body === '

') body = ''; + + var saveBtn = document.getElementById('btn-note-save'); + saveBtn.disabled = true; + updateStatus('Saving…', 'saving'); + + var isNew = _activeId == null; + var url = isNew ? '/api/notes' : '/api/notes/' + _activeId; + var method = isNew ? 'POST' : 'PUT'; + + fetch(url, { + method: method, + headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()), + credentials: 'include', + body: JSON.stringify({ title: title, body: body }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + saveBtn.disabled = false; + if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; } + if (isNew && data.id) _activeId = data.id; + _dirty = false; + updateStatus('Saved', 'ok'); + return fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success) _notes = d.notes || []; + // Update meta line with the new updated_at. + var note = _notes.find(function(n) { return n.id === _activeId; }); + if (note) { + document.getElementById('note-meta').textContent = + 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at); + } + renderList(); + }); + }) + .catch(function(err) { + saveBtn.disabled = false; + updateStatus(err.message || 'Save failed', 'err'); + }); + } + + function deleteNote() { + if (_activeId == null) return; + var note = _notes.find(function(n) { return n.id === _activeId; }); + var label = (note && note.title) ? note.title : 'this note'; + + // Use the app's styled confirm helper if present (see public/js/app.js) + // so Daniel's no-native-alerts rule holds. + var doDelete = function() { + fetch('/api/notes/' + _activeId, { + method: 'DELETE', + headers: getAuthHeaders(), + credentials: 'include', + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; } + _notes = _notes.filter(function(n) { return n.id !== _activeId; }); + _activeId = null; + _dirty = false; + if (_editor) { _editor.destroy(); _editor = null; } + document.getElementById('notes-editor').classList.add('hidden'); + document.getElementById('notes-empty-state').classList.remove('hidden'); + renderList(); + if (typeof showToast === 'function') showToast('Note deleted', 'success'); + }) + .catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); }); + }; + + if (typeof showConfirm === 'function') { + showConfirm('Delete "' + label + '"? This cannot be undone.', doDelete); + } else { + // Fallback only fires if app.js never loaded showConfirm — shouldn't + // happen, but the CSS modal depends on app.js being present. + if (window.confirm('Delete "' + label + '"? This cannot be undone.')) doDelete(); + } + } + + // ── Voice recording → AI note ────────────────────────────── + function startRecording() { + if (_recActive) return; + // Ensure the editor is open — voice creates a fresh note if none is picked + if (document.getElementById('notes-editor').classList.contains('hidden')) { + openEditor(null); + } + if (typeof AudioRecorder === 'undefined') { + updateStatus('Recorder unavailable', 'err'); + return; + } + _recorder = new AudioRecorder(); + _recorder.start().then(function() { + _recActive = true; + _recPaused = false; + setRecUI('recording'); + startRecTimer(); + }).catch(function(err) { + updateStatus('Mic denied: ' + (err && err.message || ''), 'err'); + }); + } + + function togglePauseRecording() { + if (!_recActive || !_recorder || !_recorder.mediaRecorder) return; + if (!_recPaused) { + try { _recorder.mediaRecorder.pause(); } catch (e) {} + _recPaused = true; + stopRecTimer(); + setRecUI('paused'); + } else { + try { _recorder.mediaRecorder.resume(); } catch (e) { + // Some browsers don't support resume; restart a fresh segment + // on the same stream and keep going. + try { + if (_recorder.stream && _recorder.stream.active) { + var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; + _recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 }); + _recorder.mediaRecorder.ondataavailable = function(ev) { if (ev.data.size > 0) _recorder.chunks.push(ev.data); }; + _recorder.mediaRecorder.start(1000); + } + } catch (e2) {} + } + _recPaused = false; + startRecTimer(); + setRecUI('recording'); + } + } + + function stopRecording() { + if (!_recActive || !_recorder) return; + _recActive = false; + _recPaused = false; + stopRecTimer(true); + setRecUI('processing'); + updateStatus('Transcribing…', 'saving'); + + _recorder.stop().then(function(blob) { + if (!blob || blob.size === 0) { + updateStatus('Nothing recorded', 'err'); + setRecUI('idle'); + return; + } + if (typeof transcribeAudio !== 'function') { + updateStatus('Transcription unavailable', 'err'); + setRecUI('idle'); + return; + } + return transcribeAudio(blob).then(function(resp) { + if (!resp || !resp.success || !resp.text) { + var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed'; + updateStatus(msg, 'err'); + setRecUI('idle'); + return; + } + updateStatus('Generating note…', 'saving'); + return fetch('/api/notes/from-voice', { + method: 'POST', + credentials: 'include', + headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()), + body: JSON.stringify({ transcript: resp.text }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + setRecUI('idle'); + if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; } + applyGeneratedNote(data.title || 'Voice note', data.body || ''); + updateStatus('Generated — review and save', 'ok'); + }); + }); + }).catch(function(err) { + setRecUI('idle'); + updateStatus(err.message || 'Recording failed', 'err'); + }); + } + + function applyGeneratedNote(title, body) { + var titleEl = document.getElementById('note-title'); + if (titleEl) titleEl.value = title; + // Rebuild the Tiptap editor with the generated body so the toolbar + // stays functional and formatting is preserved. + var container = document.getElementById('note-body-editor'); + if (!container) return; + if (_editor) { _editor.destroy(); _editor = null; } + mountTiptap(container, body); + _dirty = true; + } + + function startRecTimer() { + var el = document.getElementById('notes-rec-timer'); + if (_recTimer || !el) return; + _recTimer = createTimerLite(el); + _recTimer.start(); + } + function stopRecTimer(reset) { + if (_recTimer) { _recTimer.stop(); } + if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; } + } + + // Minimal local timer (independent of app.js createTimer so multiple + // tabs can run their own timers without stepping on each other). + function createTimerLite(el) { + var s = 0, iv = null; + function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); } + return { + start: function() { paint(); if (!iv) iv = setInterval(function() { s++; paint(); }, 1000); }, + stop: function() { if (iv) { clearInterval(iv); iv = null; } }, + reset: function() { s = 0; paint(); }, + }; + } + + function setRecUI(state) { + var start = document.getElementById('btn-note-rec-start'); + var pause = document.getElementById('btn-note-rec-pause'); + var stopBtn = document.getElementById('btn-note-rec-stop'); + var ind = document.getElementById('notes-rec-indicator'); + var stateEl = document.getElementById('notes-rec-state'); + var dot = ind ? ind.querySelector('.pulse-dot') : null; + if (!start) return; + + var idle = (state === 'idle'); + var recording = (state === 'recording'); + var paused = (state === 'paused'); + var processing = (state === 'processing'); + + start.classList.toggle('hidden', !idle); + pause.classList.toggle('hidden', !(recording || paused)); + stopBtn.classList.toggle('hidden', !(recording || paused)); + ind.classList.toggle('hidden', idle); + + if (pause) pause.innerHTML = paused ? ' Resume' : ' Pause'; + if (stateEl) stateEl.textContent = processing ? 'Processing…' : (paused ? 'Paused' : 'Recording'); + if (dot) dot.classList.toggle('paused', paused || processing); + + start.disabled = !idle; + pause.disabled = processing; + stopBtn.disabled = processing; + } + + // ── Tiptap ──────────────────────────────────────────────── + function mountTiptap(container, initialHtml) { + var T = window.Tiptap || {}; + if (!T.Editor) { + container.innerHTML = ''; + _editor = null; + return; + } + container.innerHTML = toolbarHTML() + '
'; + _editor = new T.Editor({ + element: container.querySelector('.tp-content'), + extensions: [ + T.StarterKit, + T.Link.configure({ openOnClick: false, autolink: true }), + T.Underline + ], + content: initialHtml || '', + autofocus: false, + onUpdate: function() { _dirty = true; updateStatus('Unsaved', 'dirty'); updateToolbarState(container); }, + onSelectionUpdate: function() { updateToolbarState(container); } + }); + wireToolbar(container, _editor); + } + + // Built from the same buttons vanilla learningHub uses so the CSS in + // public/css/styles.css (.tp-toolbar / .tp-btn / .tp-sep / .tp-link-bar) + // styles this identically. + function toolbarHTML() { + var btns = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + return '' + + '
' + btns + '
' + + ''; + } + + function wireToolbar(wrap, ed) { + var toolbar = wrap.querySelector('.tp-toolbar'); + var linkBar = wrap.querySelector('.tp-link-bar'); + var linkInput = wrap.querySelector('.tp-link-input'); + + toolbar.addEventListener('mousedown', function(e) { + var btn = e.target.closest('.tp-btn[data-cmd]'); + if (!btn) return; + e.preventDefault(); + var cmd = btn.dataset.cmd; + switch (cmd) { + case 'bold': ed.chain().focus().toggleBold().run(); break; + case 'italic': ed.chain().focus().toggleItalic().run(); break; + case 'underline': ed.chain().focus().toggleUnderline().run(); break; + case 'strike': ed.chain().focus().toggleStrike().run(); break; + case 'h2': ed.chain().focus().toggleHeading({ level: 2 }).run(); break; + case 'h3': ed.chain().focus().toggleHeading({ level: 3 }).run(); break; + case 'bulletList': ed.chain().focus().toggleBulletList().run(); break; + case 'orderedList': ed.chain().focus().toggleOrderedList().run(); break; + case 'blockquote': ed.chain().focus().toggleBlockquote().run(); break; + case 'codeBlock': ed.chain().focus().toggleCodeBlock().run(); break; + case 'clear': ed.chain().focus().unsetAllMarks().clearNodes().run(); break; + case 'link': + if (linkBar.style.display === 'none') { + linkInput.value = ed.getAttributes('link').href || ''; + linkBar.style.display = 'flex'; + setTimeout(function() { linkInput.focus(); }, 0); + } else { + linkBar.style.display = 'none'; + } + break; + } + updateToolbarState(wrap); + }); + + wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) { + e.preventDefault(); + var url = linkInput.value.trim(); + if (url) ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + linkBar.style.display = 'none'; + }); + wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) { + e.preventDefault(); + ed.chain().focus().unsetLink().run(); + linkBar.style.display = 'none'; + }); + wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) { + e.preventDefault(); + linkBar.style.display = 'none'; + }); + linkInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); } + if (e.key === 'Escape') { linkBar.style.display = 'none'; } + }); + } + + function updateToolbarState(wrap) { + if (!_editor) return; + wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) { + var cmd = btn.dataset.cmd; + var active = false; + if (cmd === 'bold') active = _editor.isActive('bold'); + else if (cmd === 'italic') active = _editor.isActive('italic'); + else if (cmd === 'underline') active = _editor.isActive('underline'); + else if (cmd === 'strike') active = _editor.isActive('strike'); + else if (cmd === 'h2') active = _editor.isActive('heading', { level: 2 }); + else if (cmd === 'h3') active = _editor.isActive('heading', { level: 3 }); + else if (cmd === 'bulletList') active = _editor.isActive('bulletList'); + else if (cmd === 'orderedList') active = _editor.isActive('orderedList'); + else if (cmd === 'blockquote') active = _editor.isActive('blockquote'); + else if (cmd === 'codeBlock') active = _editor.isActive('codeBlock'); + else if (cmd === 'link') active = _editor.isActive('link'); + btn.classList.toggle('active', active); + }); + } + + // ── Helpers ────────────────────────────────────────────── + function updateStatus(text, kind) { + var el = document.getElementById('notes-status'); + if (!el) return; + el.textContent = text || ''; + el.className = 'notes-status' + (kind ? ' notes-status-' + kind : ''); + if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; } + if (kind === 'ok') { + _statusTimer = setTimeout(function() { el.textContent = ''; el.className = 'notes-status'; }, 1500); + } + } + + function confirmDiscard() { + // Don't prompt for unsaved discard via native dialog — if there's + // dirty content and the user clicks another note, keep it simple: + // fall through and discard. A proper "are you sure?" would be a + // showConfirm() modal, but for a personal scratchpad the friction + // outweighs the benefit. Users who care save with Ctrl/Cmd+S. + _dirty = false; + return true; + } + + function esc(s) { + return (s == null ? '' : String(s)) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function stripTags(html) { + if (!html) return ''; + var d = document.createElement('div'); + d.innerHTML = html; + return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim(); + } + + function formatWhen(iso) { + if (!iso) return ''; + var d = new Date(iso); + if (isNaN(d.getTime())) return ''; + var now = new Date(); + var sameDay = d.toDateString() === now.toDateString(); + if (sameDay) { + return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + } + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' }); + } + + // getAuthHeaders() lives in app.js. Fallback keeps the module + // standalone-testable in isolation. + function getAuthHeaders() { + if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders(); + return {}; + } +})(); diff --git a/public/sw.js b/public/sw.js index b047ac1..ec266d8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,7 +4,7 @@ // API calls always fresh (critical for medical data accuracy) // ============================================================ -var CACHE_NAME = 'pedscribe-v12'; +var CACHE_NAME = 'pedscribe-v12-notes1'; var SHELL_ASSETS = [ '/', '/index.html', diff --git a/server.js b/server.js index 2f17cce..fb07a3b 100644 --- a/server.js +++ b/server.js @@ -290,6 +290,7 @@ app.use('/api', require('./src/routes/refine')); app.use('/api', require('./src/routes/logs')); app.use('/api', require('./src/routes/encounters')); app.use('/api', require('./src/routes/memories')); +app.use('/api', require('./src/routes/notes')); app.use('/api', require('./src/routes/documents')); app.use('/api', require('./src/routes/audioBackups')); app.use('/api', require('./src/routes/billing')); diff --git a/src/routes/notes.js b/src/routes/notes.js new file mode 100644 index 0000000..1c4a5fd --- /dev/null +++ b/src/routes/notes.js @@ -0,0 +1,191 @@ +// ============================================================ +// PERSONAL NOTES ROUTES — per-user scratchpad, rich-text body. +// Pure CRUD, auth-gated. Body + title encrypted at rest (same +// crypto helper as user_memories so a row dump stays useless +// without the app key). +// ============================================================ + +var express = require('express'); +var router = express.Router(); +var db = require('../db/database'); +var { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); +var cryptoUtil = require('../utils/crypto'); +var { callAI } = require('../utils/ai'); +var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); + +router.use(authMiddleware); + +var MAX_TITLE = 200; +var MAX_BODY = 50000; // 50 KB of rich-text HTML is plenty for a clinical note +var MAX_NOTES_PER_USER = 500; + +function decryptRow(row) { + if (!row) return row; + try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {} + try { row.body = cryptoUtil.decryptString(row.body); } catch (e) {} + return row; +} + +// ── GET list ──────────────────────────────────────────────── +// Returns all notes for the current user, newest-updated first. +// Title + body are decrypted on the way out; caller renders body +// HTML through the existing sanitize-html pipeline on display. +router.get('/notes', async function (req, res) { + try { + var rows = await db.all( + 'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 ORDER BY updated_at DESC', + [req.user.id] + ); + rows.forEach(decryptRow); + res.json({ success: true, notes: rows }); + } catch (e) { + logger.error('GET /notes', e.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +// ── GET one ───────────────────────────────────────────────── +router.get('/notes/:id', async function (req, res) { + try { + var row = await db.get( + 'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE id = $1 AND user_id = $2', + [req.params.id, req.user.id] + ); + if (!row) return res.status(404).json({ error: 'Note not found' }); + res.json({ success: true, note: decryptRow(row) }); + } catch (e) { + logger.error('GET /notes/:id', e.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +// ── POST create ───────────────────────────────────────────── +router.post('/notes', async function (req, res) { + try { + var title = (req.body.title || '').trim(); + var body = (req.body.body || '').trim(); + if (!title) return res.status(400).json({ error: 'Title required' }); + + var count = await db.get('SELECT COUNT(*) as cnt FROM personal_notes WHERE user_id = $1', [req.user.id]); + if (count && parseInt(count.cnt) >= MAX_NOTES_PER_USER) { + return res.status(400).json({ error: 'Maximum ' + MAX_NOTES_PER_USER + ' notes per user' }); + } + + var result = await db.run( + 'INSERT INTO personal_notes (user_id, title, body) VALUES ($1, $2, $3) RETURNING id', + [ + req.user.id, + cryptoUtil.encryptString(title.substring(0, MAX_TITLE)), + cryptoUtil.encryptString(body.substring(0, MAX_BODY)), + ] + ); + res.json({ success: true, id: result.lastInsertRowid }); + logger.audit(req.user.id, 'create_note', 'Created personal note', req, { category: 'notes' }); + } catch (e) { + logger.error('POST /notes', e.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +// ── PUT update ────────────────────────────────────────────── +router.put('/notes/:id', async function (req, res) { + try { + var title = (req.body.title || '').trim(); + var body = (req.body.body || '').trim(); + if (!title) return res.status(400).json({ error: 'Title required' }); + + var existing = await db.get('SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); + if (!existing) return res.status(404).json({ error: 'Note not found' }); + + await db.run( + 'UPDATE personal_notes SET title = $1, body = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4', + [ + cryptoUtil.encryptString(title.substring(0, MAX_TITLE)), + cryptoUtil.encryptString(body.substring(0, MAX_BODY)), + req.params.id, + req.user.id, + ] + ); + res.json({ success: true }); + } catch (e) { + logger.error('PUT /notes/:id', e.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +// ── DELETE ────────────────────────────────────────────────── +router.delete('/notes/:id', async function (req, res) { + try { + await db.run('DELETE FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); + res.json({ success: true }); + logger.audit(req.user.id, 'delete_note', 'Deleted personal note', req, { category: 'notes' }); + } catch (e) { + logger.error('DELETE /notes/:id', e.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +// ── POST /api/notes/from-voice ────────────────────────────── +// Takes a raw voice transcript (produced by the shared +// /api/transcribe endpoint — whichever STT provider the admin +// configured, not user-selectable) and asks the AI to produce +// a clean, well-structured personal note: one short title +// followed by rich-text body HTML. Returned as-is to the client +// which drops it straight into the editor. The client stays +// in control of Save — this endpoint never touches the DB. +router.post('/notes/from-voice', async function (req, res) { + try { + var transcript = (req.body.transcript || '').trim(); + if (!transcript) return res.status(400).json({ error: 'No transcript provided' }); + + var systemPrompt = + 'You are a medical scribe turning a physician\'s dictated notes into a clean personal note.\n' + + 'Output STRICT JSON only — no preamble, no code fences, no commentary.\n' + + 'Shape: {"title": "", "body": ""}.\n' + + 'The body must be HTML using only these tags:

,

,

, , , ,