diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js deleted file mode 100644 index cb5d69e..0000000 --- a/src/routes/audioBackups.js +++ /dev/null @@ -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; diff --git a/src/routes/audioBackups.ts b/src/routes/audioBackups.ts new file mode 100644 index 0000000..cbe25af --- /dev/null +++ b/src/routes/audioBackups.ts @@ -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; diff --git a/src/routes/documents.js b/src/routes/documents.js deleted file mode 100644 index 6bca42e..0000000 --- a/src/routes/documents.js +++ /dev/null @@ -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; diff --git a/src/routes/documents.ts b/src/routes/documents.ts new file mode 100644 index 0000000..2d6df2c --- /dev/null +++ b/src/routes/documents.ts @@ -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; diff --git a/src/routes/encounters.js b/src/routes/encounters.ts similarity index 53% rename from src/routes/encounters.js rename to src/routes/encounters.ts index 6ddc876..f0909cc 100644 --- a/src/routes/encounters.js +++ b/src/routes/encounters.ts @@ -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; diff --git a/src/routes/hospitalCourse.js b/src/routes/hospitalCourse.ts similarity index 66% rename from src/routes/hospitalCourse.js rename to src/routes/hospitalCourse.ts index fe6c53e..16326f2 100644 --- a/src/routes/hospitalCourse.js +++ b/src/routes/hospitalCourse.ts @@ -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; diff --git a/src/routes/memories.js b/src/routes/memories.js deleted file mode 100644 index ed5c321..0000000 --- a/src/routes/memories.js +++ /dev/null @@ -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; diff --git a/src/routes/memories.ts b/src/routes/memories.ts new file mode 100644 index 0000000..11752e6 --- /dev/null +++ b/src/routes/memories.ts @@ -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;