Four changes batched: 1. ED Encounters tab (new) — multi-stage emergency note with don't-miss tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters (generate per-stage + finalize MDM), new ed-encounters.js owning all client logic, new ed-encounter.html component, new template_ed memory category. Persists draft to localStorage every keystroke and to saved_encounters on stage advance. encounters.js touched only to register the new tab in sessionStorage restore + tabMap (save and idempotency code untouched). 2. Notes model selector — /notes/from-voice now accepts a client-supplied model (validated by the existing callAI allow-list); falls back to the admin default. Added <select class="tab-model-select"> to notes.html so the existing app.js populator handles options + default. 3. Remove AI-learning-from-corrections — deleted correctionTracker.js, POST /memories/correction, the corrections branch in /memories/context, the settings UI section, the FAQ entry, and all dead trackAIOutput/saveCorrection guards in callers. Legacy correction_* DB rows are filtered (NOT LIKE) rather than dropped, so no destructive migration. 4. Fix notes AI framing — /notes/from-voice prompt no longer assumes "physician dictation". Plain notes (shopping lists, reminders, ideas) now match the dictation tone instead of being forced into clinical structure. All 46 tests pass.
304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
// ============================================================
|
|
// 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');
|
|
var { marked } = require('marked');
|
|
|
|
// Convert AI output to clean HTML for Tiptap. Tiptap's setContent
|
|
// only handles HTML — markdown comes through as literal text. Real-
|
|
// world models ignore "output HTML only" prompts ~25% of the time
|
|
// and return mixed markdown/HTML. We normalise both:
|
|
// • Body looks like HTML (has tags) → return as-is.
|
|
// • Body looks like markdown OR plain text → run through marked.
|
|
function toHtmlBody(s) {
|
|
if (!s) return '';
|
|
var t = String(s).trim();
|
|
// Strip ```html / ``` code fences a model sometimes wraps the body in.
|
|
t = t.replace(/^```(?:html|markdown|md)?\s*/i, '').replace(/\s*```$/i, '');
|
|
// Already HTML if there's a real tag.
|
|
if (/<(p|div|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|code|pre|br|hr|span)[\s>]/i.test(t)) return t;
|
|
// Markdown → HTML. marked handles plain text fine — paragraphs become <p>.
|
|
try { return marked.parse(t, { breaks: true }); }
|
|
catch (e) {
|
|
// Last-ditch: wrap the raw text in <p> so the editor at least shows it.
|
|
return '<p>' + t.replace(/</g, '<').replace(/>/g, '>').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
|
|
}
|
|
}
|
|
|
|
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 (active only — items in trash are filtered out) ────
|
|
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 AND deleted_at IS NULL 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 trash list — items the user soft-deleted, newest-deleted first ──
|
|
router.get('/notes/trash', async function (req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
'SELECT id, title, body, created_at, updated_at, deleted_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
|
|
[req.user.id]
|
|
);
|
|
rows.forEach(decryptRow);
|
|
res.json({ success: true, notes: rows });
|
|
} catch (e) {
|
|
logger.error('GET /notes/trash', 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 = soft-delete (move to trash) ───────────────────────
|
|
// Sets deleted_at = NOW(); the row stays in personal_notes but is
|
|
// filtered out of the active list. Use POST /notes/:id/restore to
|
|
// undo, or DELETE /notes/:id?hard=1 to actually remove.
|
|
router.delete('/notes/:id', async function (req, res) {
|
|
try {
|
|
var hard = req.query.hard === '1' || req.query.hard === 'true';
|
|
if (hard) {
|
|
// Hard-delete only items already in trash, so a UI bug can't
|
|
// accidentally erase an active note.
|
|
await db.run(
|
|
'DELETE FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
logger.audit(req.user.id, 'delete_note_hard', 'Permanently deleted personal note', req, { category: 'notes' });
|
|
} else {
|
|
await db.run(
|
|
'UPDATE personal_notes SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
logger.audit(req.user.id, 'delete_note', 'Moved personal note to trash', req, { category: 'notes' });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
logger.error('DELETE /notes/:id', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
// ── POST /notes/:id/restore — pull a note out of trash ─────────
|
|
router.post('/notes/:id/restore', async function (req, res) {
|
|
try {
|
|
var existing = await db.get(
|
|
'SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (!existing) return res.status(404).json({ error: 'Note not in trash' });
|
|
|
|
await db.run(
|
|
'UPDATE personal_notes SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
res.json({ success: true });
|
|
logger.audit(req.user.id, 'restore_note', 'Restored personal note from trash', req, { category: 'notes' });
|
|
} catch (e) {
|
|
logger.error('POST /notes/:id/restore', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
// ── POST /notes/trash/empty — hard-delete every trashed note for the user ─
|
|
router.post('/notes/trash/empty', async function (req, res) {
|
|
try {
|
|
var result = await db.run(
|
|
'DELETE FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL',
|
|
[req.user.id]
|
|
);
|
|
res.json({ success: true, removed: result.changes || 0 });
|
|
logger.audit(req.user.id, 'empty_notes_trash', 'Emptied notes trash', req, { category: 'notes' });
|
|
} catch (e) {
|
|
logger.error('POST /notes/trash/empty', 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 turn a voice dictation into a clean, well-structured personal note.\n' +
|
|
'The note may be anything: a clinical observation, a shopping list, a reminder, a travel idea, a journal entry. Match the dictation — do not assume it is medical and do not impose clinical structure (assessment/plan, SOAP, etc.) on non-clinical content.\n' +
|
|
'Output STRICT JSON only — no preamble, no code fences, no commentary.\n' +
|
|
'Shape: {"title": "<short descriptive title, max 80 chars>", "body": "<HTML body>"}.\n' +
|
|
'The body must be HTML using only these tags: <p>, <h2>, <h3>, <strong>, <em>, <u>, <ul>, <ol>, <li>, <blockquote>, <code>, <a href="…">, <br>.\n' +
|
|
'Tone follows the dictation: concise clinical prose if clinical, plain prose or a list if casual. Preserve every fact the speaker dictated — never invent, never drop.\n' +
|
|
'If the dictation is fragmentary, still produce a title and a clear body. Return pure JSON.' +
|
|
INJECTION_GUARD;
|
|
|
|
var userContent =
|
|
'Voice dictation to convert into a personal note:\n' +
|
|
wrapUserText('dictation', transcript);
|
|
|
|
// Model: prefer the client's selection (validated against the admin
|
|
// allow-list inside callAI), fall back to the admin-configured default,
|
|
// then to the LITELLM_DEFAULT_MODEL env var. Passing an empty string
|
|
// to LiteLLM returns a 400, so we guard against that.
|
|
var requested = (req.body.model || '').trim();
|
|
var adminDefault = '';
|
|
try { adminDefault = await db.getSetting('models.default'); } catch (e) {}
|
|
var fallback = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
|
|
var model = requested || fallback;
|
|
var opts = { maxTokens: 4000 };
|
|
if (model) opts.model = model;
|
|
|
|
var result = await callAI([
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userContent }
|
|
], opts);
|
|
|
|
// Models sometimes wrap JSON in ```json fences, prepend "Here you go:",
|
|
// or close the JSON early. Strip leading prose and any trailing text
|
|
// after the last } to recover.
|
|
var raw = (result.content || '').trim();
|
|
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
var jsonStart = raw.indexOf('{');
|
|
if (jsonStart > 0) raw = raw.substring(jsonStart);
|
|
var jsonEnd = raw.lastIndexOf('}');
|
|
if (jsonEnd > -1 && jsonEnd < raw.length - 1) raw = raw.substring(0, jsonEnd + 1);
|
|
|
|
var parsed = null;
|
|
try { parsed = JSON.parse(raw); } catch (e) { /* fallback below */ }
|
|
|
|
var title, body;
|
|
if (parsed && (parsed.title || parsed.body)) {
|
|
title = String(parsed.title || '').trim();
|
|
body = toHtmlBody(parsed.body || '');
|
|
} else {
|
|
// Model didn't cooperate at all — treat the whole response as the
|
|
// body and derive a title from the first sentence of the transcript.
|
|
title = transcript.split(/[.\n]/)[0].trim().substring(0, 80) || 'Voice note';
|
|
body = toHtmlBody(result.content || transcript);
|
|
}
|
|
|
|
// Final guards: never send back an empty body — at minimum show the
|
|
// raw transcript wrapped in <p> so the user can edit it manually.
|
|
if (!body || !body.replace(/<[^>]+>/g, '').trim()) {
|
|
body = '<p>' + String(transcript).replace(/</g, '<').replace(/>/g, '>') + '</p>';
|
|
}
|
|
if (!title) title = 'Voice note';
|
|
|
|
res.json({
|
|
success: true,
|
|
title: title.substring(0, 200),
|
|
body: body,
|
|
model: result.model,
|
|
});
|
|
logger.audit(req.user.id, 'note_from_voice', 'AI-generated note from voice', req, { category: 'notes' });
|
|
} catch (err) {
|
|
logger.error('POST /notes/from-voice', err.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|