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