pediatric-ai-scribe-v3/src/routes/notes.js
Daniel cc76c66953
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: the signed-out preview never worked, because /api was gated wholesale
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with
no path. Mounted that way, the gate applies to every /api request that reaches
the router — including routes belonging to routers mounted further down
server.js. extensions.js did it from line 295; the assistant is mounted at 305.
So a signed-out request to /api/clinical-assistant/status was refused ten lines
before the preview middleware could look at it, whatever the admin setting said.
server.js line 250 already warned about this shape.

Each gate now names its own prefix, so a router protects its own routes and
nothing else. Verified afterwards that every namespace which must stay shut
still answers 401 signed out: extensions, encounters, memories, notes, diagrams,
generated images, image jobs, documents, audio backups, ED encounters,
don't-miss, patient education, billing, well visit, admin, transcribe and the
rest. Two of these routers were gating routes nobody realised they were gating.

Second defect in the same path: authMiddleware only ever looks for a token, so
calling it unconditionally after the preview identity had been assigned rejected
exactly the requests preview exists to serve. Only that identity may skip it;
authMiddleware stays strict everywhere else.

Preview now answers with a real cited answer, and stays as narrow as it was
designed to be — four allow-listed paths, no identity, nothing ownable. A test
now walks every /api router and fails on a blanket gate, which is how the last
six were found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:00:59 +02:00

299 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, '&lt;').replace(/>/g, '&gt;').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
}
}
// Scoped to this router's own prefix. These routers are mounted on /api, so a
// bare router.use(authMiddleware) gated every /api path — including routes
// belonging to routers mounted after it. That is what kept the signed-out
// assistant preview returning 401 no matter what the admin setting said.
router.use('/notes', 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);
// Shared AI policy resolves omitted selections to an enabled default.
var opts = { maxTokens: 4000, model: req.body.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, '&lt;').replace(/>/g, '&gt;') + '</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;