// ============================================================ // 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

. try { return marked.parse(t, { breaks: true }); } catch (e) { // Last-ditch: wrap the raw text in

so the editor at least shows it. return '

' + t.replace(//g, '>').replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

'; } } 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": "", "body": ""}.\n' + 'The body must be HTML using only these tags:

,

,

, , , ,