Extends the existing crypto helper (already used for audio backups and the Nextcloud token) to cover every column that can hold PHI: - saved_encounters.transcript, .generated_note, .partial_data - user_memories.content (templates + Dragon-style corrections) - user_memories.name (auto-derived from original snippet on corrections, so effectively PHI) Reads decrypt transparently. Legacy plaintext rows continue to work — decryptString passes non-enc1: values through unchanged — so no migration is required; rows re-encrypt on their next save. The encounters list query previously used LEFT(transcript, 200) for a preview. With ciphertext that slice is meaningless, so the route now fetches the full columns, decrypts in Node, then slices. At 7-day auto- delete the row count is bounded and the cost is a handful of GCM decrypts per list call. user_memories ORDER BY moved from (category, name) to (category, id) since SQL can no longer order on encrypted names. Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
181 lines
8.4 KiB
JavaScript
181 lines
8.4 KiB
JavaScript
// ============================================================
|
|
// MEMORIES ROUTES — User template/memory storage
|
|
// ============================================================
|
|
|
|
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');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
// Decrypt a row's user-facing fields. Safe against legacy plaintext rows —
|
|
// cryptoUtil.decryptString passes through values without the "enc1:" prefix.
|
|
function decryptMemory(row) {
|
|
if (!row) return row;
|
|
try { row.name = cryptoUtil.decryptString(row.name); } catch (e) {}
|
|
try { row.content = cryptoUtil.decryptString(row.content); } catch (e) {}
|
|
return row;
|
|
}
|
|
|
|
var VALID_CATEGORIES = [
|
|
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
|
|
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
|
|
'correction_soap', 'correction_hpi', 'correction_encounter', 'correction_wellvisit', 'correction_sickvisit'
|
|
];
|
|
|
|
// ── GET all memories for current user ───────────────────────────────────
|
|
router.get('/memories', async function(req, res) {
|
|
try {
|
|
// Cannot ORDER BY encrypted `name`; order by id as a stable proxy after
|
|
// per-category sort. Frontend can re-sort alphabetically client-side.
|
|
var rows = await db.all(
|
|
'SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 ORDER BY category, id',
|
|
[req.user.id]
|
|
);
|
|
rows.forEach(decryptMemory);
|
|
res.json({ success: true, memories: rows });
|
|
} catch (e) { logger.error('GET /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── POST create memory ───────────────────────────────────────────────────
|
|
router.post('/memories', async function(req, res) {
|
|
try {
|
|
var { name, category, content } = req.body;
|
|
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
|
if (!content || !content.trim()) return res.status(400).json({ error: 'Content required' });
|
|
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
|
|
|
// Limit per user
|
|
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
|
|
if (count && parseInt(count.cnt) >= 200) return res.status(400).json({ error: 'Maximum 200 memories per user' });
|
|
|
|
var result = await db.run(
|
|
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
|
[
|
|
req.user.id,
|
|
cat,
|
|
cryptoUtil.encryptString(name.trim().substring(0, 100)),
|
|
cryptoUtil.encryptString(content.trim().substring(0, 5000))
|
|
]
|
|
);
|
|
res.json({ success: true, id: result.lastInsertRowid });
|
|
} catch (e) { logger.error('POST /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── PUT update memory ────────────────────────────────────────────────────
|
|
router.put('/memories/:id', async function(req, res) {
|
|
try {
|
|
var { name, category, content } = req.body;
|
|
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
|
await db.run(
|
|
'UPDATE user_memories SET name=$1, category=$2, content=$3, updated_at=NOW() WHERE id=$4 AND user_id=$5',
|
|
[
|
|
cryptoUtil.encryptString((name || '').trim().substring(0, 100)),
|
|
cat,
|
|
cryptoUtil.encryptString((content || '').trim().substring(0, 5000)),
|
|
req.params.id,
|
|
req.user.id
|
|
]
|
|
);
|
|
res.json({ success: true });
|
|
} catch (e) { logger.error('PUT /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── DELETE memory ────────────────────────────────────────────────────────
|
|
router.delete('/memories/:id', async function(req, res) {
|
|
try {
|
|
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
|
res.json({ success: true });
|
|
} catch (e) { logger.error('DELETE /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── GET memories as prompt context (for AI generation) ──────────────────
|
|
router.get('/memories/context', async function(req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
'SELECT category, name, content FROM user_memories WHERE user_id = $1 ORDER BY category',
|
|
[req.user.id]
|
|
);
|
|
if (rows.length === 0) return res.json({ success: true, context: '' });
|
|
rows.forEach(decryptMemory);
|
|
|
|
var templates = [];
|
|
var corrections = [];
|
|
rows.forEach(function(r) {
|
|
if (r.category.startsWith('correction_')) corrections.push(r);
|
|
else templates.push(r);
|
|
});
|
|
|
|
var context = '';
|
|
if (templates.length > 0) {
|
|
context += '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
|
|
templates.forEach(function(r) {
|
|
context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
|
|
});
|
|
}
|
|
if (corrections.length > 0) {
|
|
context += '\n\nSTYLE CORRECTIONS (these are examples of past edits — use only the writing style, never the clinical content):\n';
|
|
corrections.slice(-10).forEach(function(r) {
|
|
// Trim to just the key style difference, not full encounter text
|
|
var lines = r.content.split('\n').filter(function(l) { return l.trim(); });
|
|
var orig = '', corr = '';
|
|
var inCorrected = false;
|
|
lines.forEach(function(l) {
|
|
if (l.startsWith('CORRECTED TO:')) { inCorrected = true; corr = l.replace('CORRECTED TO:', '').trim(); }
|
|
else if (l.startsWith('ORIGINAL:')) { orig = l.replace('ORIGINAL:', '').trim(); }
|
|
else if (inCorrected) { corr += ' ' + l.trim(); }
|
|
else { orig += ' ' + l.trim(); }
|
|
});
|
|
// Keep only first 200 chars of each to avoid flooding the prompt
|
|
orig = orig.substring(0, 200);
|
|
corr = corr.substring(0, 200);
|
|
if (orig && corr) {
|
|
context += '- Before: "' + orig + '..."\n After: "' + corr + '..."\n';
|
|
}
|
|
});
|
|
}
|
|
res.json({ success: true, context: context.trim() });
|
|
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── POST auto-save correction (Dragon-like learning) ──────────────────
|
|
router.post('/memories/correction', async function(req, res) {
|
|
try {
|
|
var { section, original_snippet, corrected_snippet } = req.body;
|
|
if (!section || !original_snippet || !corrected_snippet) {
|
|
return res.status(400).json({ error: 'section, original_snippet, and corrected_snippet required' });
|
|
}
|
|
if (original_snippet.trim() === corrected_snippet.trim()) {
|
|
return res.json({ success: true, skipped: true });
|
|
}
|
|
var cat = 'correction_' + section;
|
|
if (!VALID_CATEGORIES.includes(cat)) cat = 'correction_encounter';
|
|
|
|
// Limit corrections per category: keep only latest 20
|
|
var existing = await db.all(
|
|
'SELECT id FROM user_memories WHERE user_id = $1 AND category = $2 ORDER BY created_at ASC',
|
|
[req.user.id, cat]
|
|
);
|
|
if (existing.length >= 20) {
|
|
// Delete oldest to make room
|
|
var toDelete = existing.slice(0, existing.length - 19);
|
|
for (var i = 0; i < toDelete.length; i++) {
|
|
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [toDelete[i].id, req.user.id]);
|
|
}
|
|
}
|
|
|
|
var name = original_snippet.substring(0, 60).replace(/\n/g, ' ') + '...';
|
|
var content = 'ORIGINAL: ' + original_snippet.substring(0, 2000) + '\nCORRECTED TO: ' + corrected_snippet.substring(0, 2000);
|
|
|
|
await db.run(
|
|
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
|
[req.user.id, cat, cryptoUtil.encryptString(name), cryptoUtil.encryptString(content)]
|
|
);
|
|
res.json({ success: true });
|
|
} catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
module.exports = router;
|