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 87654b6005
commit 4434df89b2
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
// ============================================================
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');
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);
// Normalise partial_data to a string before encrypting so JSON objects and
// raw strings round-trip identically. Decryption returns the original string.
function partialToString(partial_data) {
function partialToString(partial_data: unknown): string {
if (partial_data == null) return '{}';
if (typeof partial_data === 'object') return JSON.stringify(partial_data);
return partial_data;
return String(partial_data);
}
// ── 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 {
// transcript/generated_note are encrypted at rest, so we can't slice on
// 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(
const 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",
[req.user.id]
[req.user!.id]
);
var out = rows.map(function (r) {
var t = '', n = '';
const out = rows.map((r: any) => {
let t = '', n = '';
try { t = cryptoUtil.decryptString(r.transcript) || ''; } catch (e) {}
try { n = cryptoUtil.decryptString(r.generated_note) || ''; } catch (e) {}
return {
@ -43,49 +38,44 @@ router.get('/encounters/saved', async function(req, res) {
updated_at: r.updated_at,
expires_at: r.expires_at,
transcript_preview: t.substring(0, 200),
note_preview: n.substring(0, 200)
note_preview: n.substring(0, 200),
};
});
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 ───────────────────────────────────────────
router.get('/encounters/saved/:id', async function(req, res) {
router.get('/encounters/saved/:id', async function (req: Request, res: Response) {
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()',
[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' });
try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {}
try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } 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' });
try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {}
try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } 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' });
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 ─────────────────────────────────
router.post('/encounters/saved', async function(req, res) {
router.post('/encounters/saved', async function (req: Request, res: Response) {
try {
var { 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 { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body;
const autoDeleteDays = parseInt((await db.getSetting('site.auto_delete_days')) || '7', 10);
if (id) {
// Update existing — optimistic locking via `version` column.
// 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]);
const 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' });
var expected = req.body.expected_version;
const expected = req.body.expected_version;
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 });
}
var newVersion = (Number(existing.version) || 1) + 1;
var upd = await db.run(
const newVersion = (Number(existing.version) || 1) + 1;
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)',
[
label || 'Untitled',
@ -94,35 +84,31 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(partialToString(partial_data)),
status || 'active',
newVersion,
id, req.user.id,
expected != null ? Number(expected) : null
id, req.user!.id,
expected != null ? Number(expected) : null,
]
);
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.' });
}
res.json({ success: true, id: id, version: newVersion });
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
res.json({ success: true, id, version: newVersion });
logger.audit(req.user!.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
} else {
// Enforce unique label per user (within active/non-expired encounters)
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()",
[req.user.id, label.trim()]
[req.user!.id, label.trim()]
);
if (labelDup) {
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) {
var dup = await db.get(
const dup = await db.get(
'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) {
// Update existing instead of creating duplicate
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',
[
@ -131,18 +117,17 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(generated_note || ''),
cryptoUtil.encryptString(partialToString(partial_data)),
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 });
}
}
// Create new
var result = await db.run(
const 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)',
[
req.user.id,
req.user!.id,
label || 'Untitled',
enc_type || 'encounter',
cryptoUtil.encryptString(transcript || ''),
@ -150,26 +135,26 @@ router.post('/encounters/saved', async function(req, res) {
cryptoUtil.encryptString(partialToString(partial_data)),
status || 'active',
idempotency_key || null,
autoDeleteDays
autoDeleteDays,
]
);
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 ───────────────────────────────────────────────
router.delete('/encounters/saved/:id', async function(req, res) {
router.delete('/encounters/saved/:id', async function (req: Request, res: Response) {
try {
var result = await db.run(
const result = await db.run(
'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' });
res.json({ success: true });
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' }); }
logger.audit(req.user!.id, 'encounter_delete', 'Deleted encounter ' + req.params.id, req, { category: 'clinical' });
} 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');
const router = express.Router();
import express = require('express');
import type { Request, Response } from 'express';
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
const logger = require('../utils/logger');
const { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
// Generate hospital course
router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
const router = express.Router();
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 {
const {
notes, // Array of { date, type, content }
edNote, // { date, content, labs }
hAndP, // { date, content }
labs, // Array of { date, values }
notes,
edNote,
hAndP,
labs,
patientAge, patientGender, pmh,
setting, // 'floor' | 'picu' | 'nicu' | 'psych'
los, // length of stay
setting,
los,
model,
formatPreference, // 'auto' | 'prose' | 'dayByDay' | 'organSystem'
formatPreference,
additionalInstructions,
physicianMemories
} = req.body;
physicianMemories,
} = 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) {
return res.status(400).json({ error: 'No notes provided' });
}
// Determine format
let prompt;
let format = formatPreference || 'auto';
let prompt: string;
let format: string = formatPreference || 'auto';
if (format === 'auto') {
if (setting === 'picu' || setting === 'nicu') {
format = 'organSystem';
} else if (setting === 'psych') {
format = 'psych';
} else if (los && parseInt(los) > 3) {
format = 'dayByDay';
} else {
format = 'prose';
}
if (setting === 'picu' || setting === 'nicu') format = 'organSystem';
else if (setting === 'psych') format = 'psych';
else if (los && parseInt(los as string) > 3) format = 'dayByDay';
else format = 'prose';
}
switch (format) {
@ -50,7 +64,6 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
default: prompt = PROMPTS.hospitalCourseShort;
}
// Build the complete clinical data
let clinicalData = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
if (pmh) clinicalData += `\nPMH: ` + wrapUserText('pmh', pmh);
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 || '');
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) => {
clinicalData += `\n\n=== ${(note.type || 'Progress Note').toUpperCase()} - ${note.date || `Note ${i + 1}`} ===\n` + wrapUserText('note', note.content || '');
});
if (labs && labs.length > 0) {
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) {
@ -81,29 +94,28 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
const result = await callAI([
{ role: 'system', content: prompt + INJECTION_GUARD },
{ role: 'user', content: clinicalData }
{ role: 'user', content: clinicalData },
], { model, maxTokens: 6000 });
res.json({
success: true,
hospitalCourse: result.content,
format: format,
model: result.model
format,
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) {
res.status(500).json({ error: 'Request failed' });
}
});
// Ask for clarification/missing info
router.post('/hospital-course-clarify', authMiddleware, async (req, res) => {
router.post('/hospital-course-clarify', authMiddleware, async (req: Request, res: Response) => {
try {
const { currentDraft, notes } = req.body;
const result = await callAI([
{ 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 });
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, res) => {
router.post('/hospital-course-update', authMiddleware, async (req: Request, res: Response) => {
try {
const { currentCourse, updates, model, instructions } = req.body;
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);
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.
If adding discharge day information, add it at the end.
${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` + INJECTION_GUARD },
{ role: 'user', content: userMsg }
${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` + INJECTION_GUARD,
},
{ role: 'user', content: userMsg },
], { model, maxTokens: 6000 });
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;