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:
parent
87654b6005
commit
4434df89b2
8 changed files with 518 additions and 553 deletions
|
|
@ -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;
|
||||
92
src/routes/audioBackups.ts
Normal file
92
src/routes/audioBackups.ts
Normal 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;
|
||||
|
|
@ -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
151
src/routes/documents.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
167
src/routes/memories.ts
Normal 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;
|
||||
Loading…
Reference in a new issue