refactor(ts): day 3 batch 4 — hospital course, encounters, memories, documents, audio backups

hospitalCourse.ts — /api/generate-hospital-course + clarify/update
  encounters.ts     — /api/encounters/saved CRUD with optimistic locking
  memories.ts       — /api/memories CRUD + /context + /correction
  documents.ts      — S3-backed /api/documents upload/list/download/delete
  audioBackups.ts   — /api/audio-backups gzip+AES storage

19 of 29 routes converted. 19/54 backend files. tsc --noEmit green.
This commit is contained in:
Daniel 2026-04-23 19:43:42 +02:00
parent c48406536b
commit 9da31b4d34
8 changed files with 518 additions and 553 deletions

View file

@ -1,98 +0,0 @@
// ============================================================
// AUDIO BACKUPS ROUTES — Server-side audio backup storage
// Stores compressed audio in PostgreSQL, auto-deletes after 24h
// ============================================================
var express = require('express');
var router = express.Router();
var zlib = require('zlib');
var multer = require('multer');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var cryptoUtil = require('../utils/crypto');
var { serverError } = require('../utils/errors');
// 25MB upload limit (same as transcribe)
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
router.use(authMiddleware);
// ── POST save audio backup (compressed) ──────────────────────────────────
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
try {
if (!req.file) return res.status(400).json({ error: 'No audio file' });
var module = req.body.module || 'encounter';
var originalSize = req.file.size;
// Gzip compress, then AES-256-GCM encrypt the audio data.
var compressed = await new Promise(function(resolve, reject) {
zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
var stored = cryptoUtil.encryptBuffer(compressed);
var result = await db.run(
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored]
);
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user.id);
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
} catch (e) {
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
}
});
// ── GET list audio backups ───────────────────────────────────────────────
router.get('/audio-backups', async function(req, res) {
try {
var rows = await db.all(
"SELECT id, module, mime_type, size_bytes, compressed_bytes, created_at, expires_at FROM audio_backups WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC",
[req.user.id]
);
res.json({ success: true, backups: rows });
} catch (e) { return serverError(res, 'AudioBackup list', e, 'Could not list backups'); }
});
// ── GET download audio backup (decompressed) ─────────────────────────────
router.get('/audio-backups/:id/audio', async function(req, res) {
try {
var row = await db.get(
'SELECT audio_data, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Backup not found or expired' });
// Decrypt (if encrypted row) then gunzip. Legacy rows are passed through.
var ciphertext = row.audio_data;
var gzipped = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext;
var decompressed = await new Promise(function(resolve, reject) {
zlib.gunzip(gzipped, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
res.setHeader('Content-Length', decompressed.length);
res.send(decompressed);
} catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); }
});
// ── DELETE audio backup ──────────────────────────────────────────────────
router.delete('/audio-backups/:id', async function(req, res) {
try {
var result = await db.run(
'DELETE FROM audio_backups WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); }
});
module.exports = router;

View file

@ -0,0 +1,92 @@
// ============================================================
// AUDIO BACKUPS ROUTES — Server-side audio backup storage
// Stores compressed audio in PostgreSQL, auto-deletes after 24h
// ============================================================
import express = require('express');
import type { Request, Response } from 'express';
import zlib = require('zlib');
import multer = require('multer');
const db = require('../db/database');
const { authMiddleware } = require('../middleware/auth');
const cryptoUtil = require('../utils/crypto');
const { serverError } = require('../utils/errors');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
const router = express.Router();
router.use(authMiddleware);
router.post('/audio-backups', upload.single('audio'), async function (req: Request, res: Response) {
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
try {
if (!req.file) return res.status(400).json({ error: 'No audio file' });
const module = req.body.module || 'encounter';
const originalSize = req.file.size;
const compressed: Buffer = await new Promise((resolve, reject) => {
zlib.gzip(req.file!.buffer, { level: 6 }, (err, result) => {
if (err) reject(err); else resolve(result);
});
});
const stored = cryptoUtil.encryptBuffer(compressed);
const result = await db.run(
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user!.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored]
);
const ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user!.id);
res.json({ success: true, id: result.lastInsertRowid, originalSize, compressedSize: compressed.length });
} catch (e) {
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
}
});
router.get('/audio-backups', async function (req: Request, res: Response) {
try {
const rows = await db.all(
"SELECT id, module, mime_type, size_bytes, compressed_bytes, created_at, expires_at FROM audio_backups WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC",
[req.user!.id]
);
res.json({ success: true, backups: rows });
} catch (e) { return serverError(res, 'AudioBackup list', e, 'Could not list backups'); }
});
router.get('/audio-backups/:id/audio', async function (req: Request, res: Response) {
try {
const row = await db.get(
'SELECT audio_data, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
[req.params.id, req.user!.id]
);
if (!row) return res.status(404).json({ error: 'Backup not found or expired' });
const ciphertext = row.audio_data;
const gzipped: Buffer = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext;
const decompressed: Buffer = await new Promise((resolve, reject) => {
zlib.gunzip(gzipped, (err, result) => {
if (err) reject(err); else resolve(result);
});
});
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
res.setHeader('Content-Length', decompressed.length);
res.send(decompressed);
} catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); }
});
router.delete('/audio-backups/:id', async function (req: Request, res: Response) {
try {
const result = await db.run(
'DELETE FROM audio_backups WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); }
});
export = router;

View file

@ -1,164 +0,0 @@
// ============================================================
// DOCUMENTS ROUTES — S3-backed document upload & management
// ============================================================
var express = require('express');
var router = express.Router();
var multer = require('multer');
var crypto = require('crypto');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
var upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
});
var ALLOWED_TYPES = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv'
];
// Lazy-load S3 client
var _s3Client = null;
function getS3Client() {
if (_s3Client) return _s3Client;
try {
var { S3Client } = require('@aws-sdk/client-s3');
var region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
var config = { region: region };
// Custom endpoint for S3-compatible providers (Backblaze B2, MinIO, etc.)
if (process.env.S3_ENDPOINT) {
config.endpoint = process.env.S3_ENDPOINT;
config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'; // Required for MinIO
}
// Credentials: use S3-specific keys first, fall back to AWS keys
var accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
var secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
if (accessKey && secretKey) {
config.credentials = {
accessKeyId: accessKey,
secretAccessKey: secretKey
};
}
_s3Client = new S3Client(config);
return _s3Client;
} catch (e) {
return null;
}
}
function isS3Configured() {
return !!process.env.S3_BUCKET && !!getS3Client();
}
// ── GET list user documents ────────────────────────────────────────────
router.get('/documents', async function(req, res) {
try {
if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
var rows = await db.all(
'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id]
);
res.json({ success: true, documents: rows, s3_configured: true });
} catch (e) { logger.error('GET /documents', e.message); res.status(500).json({ error: 'Could not list documents' }); }
});
// ── POST upload document ───────────────────────────────────────────────
router.post('/documents/upload', upload.single('file'), async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
}
// Magic-byte verification — browser-reported MIME can be spoofed.
var fileType = require('../utils/fileType');
if (!fileType.matches(req.file.mimetype, req.file.buffer)) {
return res.status(400).json({ error: 'File contents do not match the declared type.' });
}
var { PutObjectCommand } = require('@aws-sdk/client-s3');
var prefix = process.env.S3_PREFIX || 'documents/';
var uuid = crypto.randomUUID();
var s3Key = prefix + req.user.id + '/' + uuid + '/' + req.file.originalname;
await getS3Client().send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
ServerSideEncryption: 'AES256'
}));
var result = await db.run(
'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
);
res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
logger.audit(req.user.id, 'document_upload', 'Uploaded: ' + (req.file ? req.file.originalname : 'unknown'), req, { category: 'documents' });
} catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: 'Upload failed' }); }
});
// ── GET download document (presigned URL) ──────────────────────────────
router.get('/documents/:id/download', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
var { GetObjectCommand } = require('@aws-sdk/client-s3');
var { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
var command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key,
ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"'
});
var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min
res.json({ success: true, url: url });
logger.audit(req.user.id, 'document_download', 'Downloaded document ' + req.params.id, req, { category: 'documents' });
} catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: 'Download failed' }); }
});
// ── DELETE document ────────────────────────────────────────────────────
router.delete('/documents/:id', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
try {
var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
await getS3Client().send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key
}));
} catch (s3err) {
logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
}
await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
res.json({ success: true });
logger.audit(req.user.id, 'document_delete', 'Deleted document ' + req.params.id, req, { category: 'documents' });
} catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: 'Delete failed' }); }
});
module.exports = router;

151
src/routes/documents.ts Normal file
View file

@ -0,0 +1,151 @@
// ============================================================
// DOCUMENTS ROUTES — S3-backed document upload & management
// ============================================================
import express = require('express');
import type { Request, Response } from 'express';
import multer = require('multer');
import crypto = require('crypto');
const db = require('../db/database');
const { authMiddleware } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
router.use(authMiddleware);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
const ALLOWED_TYPES: string[] = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv',
];
let _s3Client: any = null;
function getS3Client(): any {
if (_s3Client) return _s3Client;
try {
const { S3Client } = require('@aws-sdk/client-s3');
const region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
const config: any = { region };
if (process.env.S3_ENDPOINT) {
config.endpoint = process.env.S3_ENDPOINT;
config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true';
}
const accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
const secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
if (accessKey && secretKey) {
config.credentials = { accessKeyId: accessKey, secretAccessKey: secretKey };
}
_s3Client = new S3Client(config);
return _s3Client;
} catch (e) {
return null;
}
}
function isS3Configured(): boolean {
return !!process.env.S3_BUCKET && !!getS3Client();
}
router.get('/documents', async function (req: Request, res: Response) {
try {
if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
const rows = await db.all(
'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
[req.user!.id]
);
res.json({ success: true, documents: rows, s3_configured: true });
} catch (e: any) { logger.error('GET /documents', e.message); res.status(500).json({ error: 'Could not list documents' }); }
});
router.post('/documents/upload', upload.single('file'), async function (req: Request, res: Response) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
}
const fileType = require('../utils/fileType');
if (!fileType.matches(req.file.mimetype, req.file.buffer)) {
return res.status(400).json({ error: 'File contents do not match the declared type.' });
}
const { PutObjectCommand } = require('@aws-sdk/client-s3');
const prefix = process.env.S3_PREFIX || 'documents/';
const uuid = crypto.randomUUID();
const s3Key = prefix + req.user!.id + '/' + uuid + '/' + req.file.originalname;
await getS3Client().send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
ServerSideEncryption: 'AES256',
}));
const result = await db.run(
'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user!.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
);
res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
logger.audit(req.user!.id, 'document_upload', 'Uploaded: ' + (req.file ? req.file.originalname : 'unknown'), req, { category: 'documents' });
} catch (e: any) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: 'Upload failed' }); }
});
router.get('/documents/:id/download', async function (req: Request, res: Response) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
const doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key,
ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"',
});
const url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 });
res.json({ success: true, url });
logger.audit(req.user!.id, 'document_download', 'Downloaded document ' + req.params.id, req, { category: 'documents' });
} catch (e: any) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: 'Download failed' }); }
});
router.delete('/documents/:id', async function (req: Request, res: Response) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
const doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
try {
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
await getS3Client().send(new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET, Key: doc.s3_key }));
} catch (s3err: any) {
logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
}
await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user!.id]);
res.json({ success: true });
logger.audit(req.user!.id, 'document_delete', 'Deleted document ' + req.params.id, req, { category: 'documents' });
} catch (e: any) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: 'Delete failed' }); }
});
export = router;

View file

@ -2,36 +2,31 @@
// ENCOUNTERS ROUTES — Save/resume/pause encounter progress // ENCOUNTERS ROUTES — Save/resume/pause encounter progress
// ============================================================ // ============================================================
var express = require('express'); import express = require('express');
var router = express.Router(); import type { Request, Response } from 'express';
var db = require('../db/database'); const db = require('../db/database');
var { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); const logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto'); const cryptoUtil = require('../utils/crypto');
const router = express.Router();
router.use(authMiddleware); router.use(authMiddleware);
// Normalise partial_data to a string before encrypting so JSON objects and function partialToString(partial_data: unknown): string {
// raw strings round-trip identically. Decryption returns the original string.
function partialToString(partial_data) {
if (partial_data == null) return '{}'; if (partial_data == null) return '{}';
if (typeof partial_data === 'object') return JSON.stringify(partial_data); if (typeof partial_data === 'object') return JSON.stringify(partial_data);
return partial_data; return String(partial_data);
} }
// ── GET all saved encounters for current user ──────────────────────────── // ── GET all saved encounters for current user ────────────────────────────
router.get('/encounters/saved', async function(req, res) { router.get('/encounters/saved', async function (req: Request, res: Response) {
try { try {
// transcript/generated_note are encrypted at rest, so we can't slice on const rows = await db.all(
// the server with LEFT(..,200). Fetch full columns, decrypt in Node, then
// preview. With a 7-day auto-delete the row count is small; the cost is
// a handful of AES-GCM decrypts per list call.
var rows = await db.all(
"SELECT id, label, enc_type, status, created_at, updated_at, expires_at, transcript, generated_note FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC", "SELECT id, label, enc_type, status, created_at, updated_at, expires_at, transcript, generated_note FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC",
[req.user.id] [req.user!.id]
); );
var out = rows.map(function (r) { const out = rows.map((r: any) => {
var t = '', n = ''; let t = '', n = '';
try { t = cryptoUtil.decryptString(r.transcript) || ''; } catch (e) {} try { t = cryptoUtil.decryptString(r.transcript) || ''; } catch (e) {}
try { n = cryptoUtil.decryptString(r.generated_note) || ''; } catch (e) {} try { n = cryptoUtil.decryptString(r.generated_note) || ''; } catch (e) {}
return { return {
@ -43,49 +38,44 @@ router.get('/encounters/saved', async function(req, res) {
updated_at: r.updated_at, updated_at: r.updated_at,
expires_at: r.expires_at, expires_at: r.expires_at,
transcript_preview: t.substring(0, 200), transcript_preview: t.substring(0, 200),
note_preview: n.substring(0, 200) note_preview: n.substring(0, 200),
}; };
}); });
res.json({ success: true, encounters: out }); res.json({ success: true, encounters: out });
} catch (e) { logger.error('GET /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); } } catch (e: any) { logger.error('GET /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); }
}); });
// ── GET single saved encounter ─────────────────────────────────────────── // ── GET single saved encounter ───────────────────────────────────────────
router.get('/encounters/saved/:id', async function(req, res) { router.get('/encounters/saved/:id', async function (req: Request, res: Response) {
try { try {
var row = await db.get( const row = await db.get(
'SELECT * FROM saved_encounters WHERE id = $1 AND user_id = $2 AND expires_at > NOW()', 'SELECT * FROM saved_encounters WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
[req.params.id, req.user.id] [req.params.id, req.user!.id]
); );
if (!row) return res.status(404).json({ error: 'Encounter not found or expired' }); if (!row) return res.status(404).json({ error: 'Encounter not found or expired' });
try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {} try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {}
try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } catch (e) {} try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } catch (e) {}
try { row.partial_data = cryptoUtil.decryptString(row.partial_data); } catch (e) {} try { row.partial_data = cryptoUtil.decryptString(row.partial_data); } catch (e) {}
logger.audit(req.user.id, 'encounter_load', 'Loaded encounter: ' + (row.label || 'unlabeled') + ' (id:' + req.params.id + ')', req, { category: 'clinical' }); logger.audit(req.user!.id, 'encounter_load', 'Loaded encounter: ' + (row.label || 'unlabeled') + ' (id:' + req.params.id + ')', req, { category: 'clinical' });
res.json({ success: true, encounter: row }); res.json({ success: true, encounter: row });
} catch (e) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); } } catch (e: any) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
}); });
// ── POST save/update encounter progress ───────────────────────────────── // ── POST save/update encounter progress ─────────────────────────────────
router.post('/encounters/saved', async function(req, res) { router.post('/encounters/saved', async function (req: Request, res: Response) {
try { try {
var { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body; const { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body;
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10); const autoDeleteDays = parseInt((await db.getSetting('site.auto_delete_days')) || '7', 10);
if (id) { if (id) {
// Update existing — optimistic locking via `version` column. const existing = await db.get('SELECT id, version FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user!.id]);
// Client passes req.body.expected_version (from the last GET). If
// it doesn't match, another tab/user wrote first and we reject
// with 409. If client doesn't send a version, we fall back to the
// old behaviour (last-write-wins) for backwards compat.
var existing = await db.get('SELECT id, version FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Not found' }); if (!existing) return res.status(404).json({ error: 'Not found' });
var expected = req.body.expected_version; const expected = req.body.expected_version;
if (expected != null && Number(expected) !== Number(existing.version || 1)) { if (expected != null && Number(expected) !== Number(existing.version || 1)) {
return res.status(409).json({ error: 'Encounter was modified in another tab or session. Reload to see the latest.', currentVersion: existing.version, yourVersion: expected }); return res.status(409).json({ error: 'Encounter was modified in another tab or session. Reload to see the latest.', currentVersion: existing.version, yourVersion: expected });
} }
var newVersion = (Number(existing.version) || 1) + 1; const newVersion = (Number(existing.version) || 1) + 1;
var upd = await db.run( const upd = await db.run(
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, version=$6, updated_at=NOW() WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)', 'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, version=$6, updated_at=NOW() WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)',
[ [
label || 'Untitled', label || 'Untitled',
@ -94,35 +84,31 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(partialToString(partial_data)), cryptoUtil.encryptString(partialToString(partial_data)),
status || 'active', status || 'active',
newVersion, newVersion,
id, req.user.id, id, req.user!.id,
expected != null ? Number(expected) : null expected != null ? Number(expected) : null,
] ]
); );
if (upd.changes === 0) { if (upd.changes === 0) {
// Someone else wrote between our SELECT and UPDATE
return res.status(409).json({ error: 'Encounter changed under you. Reload and retry.' }); return res.status(409).json({ error: 'Encounter changed under you. Reload and retry.' });
} }
res.json({ success: true, id: id, version: newVersion }); res.json({ success: true, id, version: newVersion });
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); logger.audit(req.user!.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
} else { } else {
// Enforce unique label per user (within active/non-expired encounters)
if (label && label.trim()) { if (label && label.trim()) {
var labelDup = await db.get( const labelDup = await db.get(
"SELECT id FROM saved_encounters WHERE user_id = $1 AND LOWER(label) = LOWER($2) AND expires_at > NOW()", "SELECT id FROM saved_encounters WHERE user_id = $1 AND LOWER(label) = LOWER($2) AND expires_at > NOW()",
[req.user.id, label.trim()] [req.user!.id, label.trim()]
); );
if (labelDup) { if (labelDup) {
return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' }); return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' });
} }
} }
// Check for duplicate via idempotency_key
if (idempotency_key) { if (idempotency_key) {
var dup = await db.get( const dup = await db.get(
'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2', 'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
[req.user.id, idempotency_key] [req.user!.id, idempotency_key]
); );
if (dup) { if (dup) {
// Update existing instead of creating duplicate
await db.run( await db.run(
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7', 'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
[ [
@ -131,18 +117,17 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(generated_note || ''), cryptoUtil.encryptString(generated_note || ''),
cryptoUtil.encryptString(partialToString(partial_data)), cryptoUtil.encryptString(partialToString(partial_data)),
status || 'active', status || 'active',
dup.id, req.user.id dup.id, req.user!.id,
] ]
); );
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); logger.audit(req.user!.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
return res.json({ success: true, id: dup.id }); return res.json({ success: true, id: dup.id });
} }
} }
// Create new const result = await db.run(
var result = await db.run(
'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW() + ($9 || \' days\')::INTERVAL)', 'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW() + ($9 || \' days\')::INTERVAL)',
[ [
req.user.id, req.user!.id,
label || 'Untitled', label || 'Untitled',
enc_type || 'encounter', enc_type || 'encounter',
cryptoUtil.encryptString(transcript || ''), cryptoUtil.encryptString(transcript || ''),
@ -150,26 +135,26 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(partialToString(partial_data)), cryptoUtil.encryptString(partialToString(partial_data)),
status || 'active', status || 'active',
idempotency_key || null, idempotency_key || null,
autoDeleteDays autoDeleteDays,
] ]
); );
res.json({ success: true, id: result.lastInsertRowid }); res.json({ success: true, id: result.lastInsertRowid });
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' }); logger.audit(req.user!.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
} }
} catch (e) { logger.error('POST /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); } } catch (e: any) { logger.error('POST /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); }
}); });
// ── DELETE saved encounter ─────────────────────────────────────────────── // ── DELETE saved encounter ───────────────────────────────────────────────
router.delete('/encounters/saved/:id', async function(req, res) { router.delete('/encounters/saved/:id', async function (req: Request, res: Response) {
try { try {
var result = await db.run( const result = await db.run(
'DELETE FROM saved_encounters WHERE id = $1 AND user_id = $2', 'DELETE FROM saved_encounters WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id] [req.params.id, req.user!.id]
); );
if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true }); res.json({ success: true });
logger.audit(req.user.id, 'encounter_delete', 'Deleted encounter ' + req.params.id, req, { category: 'clinical' }); logger.audit(req.user!.id, 'encounter_delete', 'Deleted encounter ' + req.params.id, req, { category: 'clinical' });
} catch (e) { logger.error('DELETE /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); } } catch (e: any) { logger.error('DELETE /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
}); });
module.exports = router; export = router;

View file

@ -1,46 +1,60 @@
const express = require('express'); import express = require('express');
const router = express.Router(); import type { Request, Response } from 'express';
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); const logger = require('../utils/logger');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); const { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
// Generate hospital course const router = express.Router();
router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
interface NoteEntry { date?: string; type?: string; content?: string }
interface EdNoteEntry { date?: string; content?: string; labs?: string }
interface HpEntry { date?: string; content?: string }
interface LabEntry { date?: string; values?: string }
router.post('/generate-hospital-course', authMiddleware, async (req: Request, res: Response) => {
try { try {
const { const {
notes, // Array of { date, type, content } notes,
edNote, // { date, content, labs } edNote,
hAndP, // { date, content } hAndP,
labs, // Array of { date, values } labs,
patientAge, patientGender, pmh, patientAge, patientGender, pmh,
setting, // 'floor' | 'picu' | 'nicu' | 'psych' setting,
los, // length of stay los,
model, model,
formatPreference, // 'auto' | 'prose' | 'dayByDay' | 'organSystem' formatPreference,
additionalInstructions, additionalInstructions,
physicianMemories physicianMemories,
} = req.body; } = req.body as {
notes: NoteEntry[];
edNote?: EdNoteEntry;
hAndP?: HpEntry;
labs?: LabEntry[];
patientAge?: string;
patientGender?: string;
pmh?: string;
setting?: 'floor' | 'picu' | 'nicu' | 'psych';
los?: string | number;
model?: string;
formatPreference?: 'auto' | 'prose' | 'dayByDay' | 'organSystem';
additionalInstructions?: string;
physicianMemories?: string;
};
if (!notes || notes.length === 0) { if (!notes || notes.length === 0) {
return res.status(400).json({ error: 'No notes provided' }); return res.status(400).json({ error: 'No notes provided' });
} }
// Determine format let prompt: string;
let prompt; let format: string = formatPreference || 'auto';
let format = formatPreference || 'auto';
if (format === 'auto') { if (format === 'auto') {
if (setting === 'picu' || setting === 'nicu') { if (setting === 'picu' || setting === 'nicu') format = 'organSystem';
format = 'organSystem'; else if (setting === 'psych') format = 'psych';
} else if (setting === 'psych') { else if (los && parseInt(los as string) > 3) format = 'dayByDay';
format = 'psych'; else format = 'prose';
} else if (los && parseInt(los) > 3) {
format = 'dayByDay';
} else {
format = 'prose';
}
} }
switch (format) { switch (format) {
@ -50,7 +64,6 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
default: prompt = PROMPTS.hospitalCourseShort; default: prompt = PROMPTS.hospitalCourseShort;
} }
// Build the complete clinical data
let clinicalData = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`; let clinicalData = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
if (pmh) clinicalData += `\nPMH: ` + wrapUserText('pmh', pmh); if (pmh) clinicalData += `\nPMH: ` + wrapUserText('pmh', pmh);
if (setting) clinicalData += `\nSetting: ${setting}`; if (setting) clinicalData += `\nSetting: ${setting}`;
@ -63,14 +76,14 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
} }
if (hAndP) clinicalData += `\n\n=== H&P (${hAndP.date || 'date unknown'}) ===\n` + wrapUserText('h_and_p', hAndP.content || ''); if (hAndP) clinicalData += `\n\n=== H&P (${hAndP.date || 'date unknown'}) ===\n` + wrapUserText('h_and_p', hAndP.content || '');
const sortedNotes = [...notes].sort((a, b) => new Date(a.date) - new Date(b.date)); const sortedNotes = [...notes].sort((a, b) => +new Date(a.date || 0) - +new Date(b.date || 0));
sortedNotes.forEach((note, i) => { sortedNotes.forEach((note, i) => {
clinicalData += `\n\n=== ${(note.type || 'Progress Note').toUpperCase()} - ${note.date || `Note ${i + 1}`} ===\n` + wrapUserText('note', note.content || ''); clinicalData += `\n\n=== ${(note.type || 'Progress Note').toUpperCase()} - ${note.date || `Note ${i + 1}`} ===\n` + wrapUserText('note', note.content || '');
}); });
if (labs && labs.length > 0) { if (labs && labs.length > 0) {
clinicalData += '\n\n=== LABS ==='; clinicalData += '\n\n=== LABS ===';
labs.forEach(lab => { clinicalData += `\n${lab.date}: ` + wrapUserText('labs', lab.values || ''); }); labs.forEach((lab) => { clinicalData += `\n${lab.date}: ` + wrapUserText('labs', lab.values || ''); });
} }
if (additionalInstructions) { if (additionalInstructions) {
@ -81,29 +94,28 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
const result = await callAI([ const result = await callAI([
{ role: 'system', content: prompt + INJECTION_GUARD }, { role: 'system', content: prompt + INJECTION_GUARD },
{ role: 'user', content: clinicalData } { role: 'user', content: clinicalData },
], { model, maxTokens: 6000 }); ], { model, maxTokens: 6000 });
res.json({ res.json({
success: true, success: true,
hospitalCourse: result.content, hospitalCourse: result.content,
format: format, format,
model: result.model model: result.model,
}); });
logger.audit(req.user.id, 'generate_hospital_course', 'Generated hospital course', req, { category: 'clinical' }); logger.audit(req.user!.id, 'generate_hospital_course', 'Generated hospital course', req, { category: 'clinical' });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Request failed' }); res.status(500).json({ error: 'Request failed' });
} }
}); });
// Ask for clarification/missing info router.post('/hospital-course-clarify', authMiddleware, async (req: Request, res: Response) => {
router.post('/hospital-course-clarify', authMiddleware, async (req, res) => {
try { try {
const { currentDraft, notes } = req.body; const { currentDraft, notes } = req.body;
const result = await callAI([ const result = await callAI([
{ role: 'system', content: PROMPTS.askClarification + INJECTION_GUARD }, { role: 'system', content: PROMPTS.askClarification + INJECTION_GUARD },
{ role: 'user', content: `Current draft:\n` + wrapUserText('draft', currentDraft || '') + `\n\nSource notes:\n` + wrapUserText('notes', JSON.stringify(notes || [])) } { role: 'user', content: `Current draft:\n` + wrapUserText('draft', currentDraft || '') + `\n\nSource notes:\n` + wrapUserText('notes', JSON.stringify(notes || [])) },
], { model: req.body.model }); ], { model: req.body.model });
res.json({ success: true, questions: result.content, model: result.model }); res.json({ success: true, questions: result.content, model: result.model });
@ -112,8 +124,7 @@ router.post('/hospital-course-clarify', authMiddleware, async (req, res) => {
} }
}); });
// Add discharge day info router.post('/hospital-course-update', authMiddleware, async (req: Request, res: Response) => {
router.post('/hospital-course-update', authMiddleware, async (req, res) => {
try { try {
const { currentCourse, updates, model, instructions } = req.body; const { currentCourse, updates, model, instructions } = req.body;
if (!currentCourse) return res.status(400).json({ error: 'No current course' }); if (!currentCourse) return res.status(400).json({ error: 'No current course' });
@ -123,11 +134,13 @@ router.post('/hospital-course-update', authMiddleware, async (req, res) => {
if (instructions) userMsg += `INSTRUCTIONS:\n` + wrapUserText('instructions', instructions); if (instructions) userMsg += `INSTRUCTIONS:\n` + wrapUserText('instructions', instructions);
const result = await callAI([ const result = await callAI([
{ role: 'system', content: `You are updating a hospital course document with new information. {
role: 'system', content: `You are updating a hospital course document with new information.
Add the new information in the appropriate place. Maintain the same format and style. Add the new information in the appropriate place. Maintain the same format and style.
If adding discharge day information, add it at the end. If adding discharge day information, add it at the end.
${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` + INJECTION_GUARD }, ${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` + INJECTION_GUARD,
{ role: 'user', content: userMsg } },
{ role: 'user', content: userMsg },
], { model, maxTokens: 6000 }); ], { model, maxTokens: 6000 });
res.json({ success: true, hospitalCourse: result.content, model: result.model }); res.json({ success: true, hospitalCourse: result.content, model: result.model });
@ -136,4 +149,4 @@ ${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` + INJECTION_GUARD },
} }
}); });
module.exports = router; export = router;

View file

@ -1,181 +0,0 @@
// ============================================================
// 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;

167
src/routes/memories.ts Normal file
View file

@ -0,0 +1,167 @@
// ============================================================
// MEMORIES ROUTES — User template/memory storage
// ============================================================
import express = require('express');
import type { Request, Response } from 'express';
const db = require('../db/database');
const { authMiddleware } = require('../middleware/auth');
const logger = require('../utils/logger');
const cryptoUtil = require('../utils/crypto');
const router = express.Router();
router.use(authMiddleware);
function decryptMemory(row: any): any {
if (!row) return row;
try { row.name = cryptoUtil.decryptString(row.name); } catch (e) {}
try { row.content = cryptoUtil.decryptString(row.content); } catch (e) {}
return row;
}
const VALID_CATEGORIES: string[] = [
'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',
];
router.get('/memories', async function (req: Request, res: Response) {
try {
const 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: any) { logger.error('GET /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
});
router.post('/memories', async function (req: Request, res: Response) {
try {
const { 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' });
const cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
const 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' });
const 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: any) { logger.error('POST /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
});
router.put('/memories/:id', async function (req: Request, res: Response) {
try {
const { name, category, content } = req.body;
const 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: any) { logger.error('PUT /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
});
router.delete('/memories/:id', async function (req: Request, res: Response) {
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: any) { logger.error('DELETE /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
});
router.get('/memories/context', async function (req: Request, res: Response) {
try {
const 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);
const templates: any[] = [];
const corrections: any[] = [];
rows.forEach((r: any) => {
if (r.category.startsWith('correction_')) corrections.push(r);
else templates.push(r);
});
let context = '';
if (templates.length > 0) {
context += '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
templates.forEach((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((r) => {
const lines = r.content.split('\n').filter((l: string) => l.trim());
let orig = '', corr = '';
let inCorrected = false;
lines.forEach((l: string) => {
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(); }
});
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: any) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: 'Request failed' }); }
});
router.post('/memories/correction', async function (req: Request, res: Response) {
try {
const { 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 });
}
let cat = 'correction_' + section;
if (!VALID_CATEGORIES.includes(cat)) cat = 'correction_encounter';
const 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) {
const toDelete = existing.slice(0, existing.length - 19);
for (const row of toDelete) {
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [row.id, req.user!.id]);
}
}
const name = original_snippet.substring(0, 60).replace(/\n/g, ' ') + '...';
const 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: any) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }
});
export = router;