Add patient management, deployment scripts, and Docker fixes

This commit is contained in:
2026-01-30 01:51:33 -08:00
parent 745f9f827f
commit d28d2f20c6
33 changed files with 7496 additions and 284 deletions

View File

@@ -92,6 +92,239 @@ app.use('/files', express.static(DATA_DIR, {
}
}));
// ============================================
// API REQUEST LOGGING MIDDLEWARE
// ============================================
/**
* Sanitize request parameters - remove sensitive data but keep structure
*/
function sanitizeParams(body, sensitiveKeys = ['password', 'token', 'secret', 'apiKey', 'authorization']) {
if (!body || typeof body !== 'object') return null;
const sanitized = {};
for (const [key, value] of Object.entries(body)) {
// Skip very large values (like base64 images)
if (typeof value === 'string' && value.length > 500) {
sanitized[key] = `[String: ${value.length} chars]`;
} else if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk.toLowerCase()))) {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
sanitized[key] = `[Array: ${value.length} items]`;
} else {
sanitized[key] = sanitizeParams(value, sensitiveKeys);
}
} else {
sanitized[key] = value;
}
}
return Object.keys(sanitized).length > 0 ? sanitized : null;
}
/**
* Extract file upload information
*/
function extractFileInfo(req) {
const files = [];
// Single file upload (req.file from multer)
if (req.file) {
files.push({
fieldname: req.file.fieldname,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
destination: req.file.destination?.replace(/\\/g, '/').split('/').slice(-2).join('/'), // Last 2 dirs only
filename: req.file.filename
});
}
// Multiple files upload (req.files from multer)
if (req.files) {
const fileList = Array.isArray(req.files) ? req.files : Object.values(req.files).flat();
for (const file of fileList) {
files.push({
fieldname: file.fieldname,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
destination: file.destination?.replace(/\\/g, '/').split('/').slice(-2).join('/'),
filename: file.filename
});
}
}
return files.length > 0 ? files : null;
}
/**
* Extract response summary - key fields from response body
*/
function extractResponseSummary(body, statusCode) {
if (!body || typeof body !== 'object') return null;
const summary = {};
// Common success indicators
if (body.success !== undefined) summary.success = body.success;
if (body.message) summary.message = body.message.substring(0, 200);
if (body.error) summary.error = typeof body.error === 'string' ? body.error.substring(0, 200) : 'Error object';
// Case-related
if (body.caseId) summary.caseId = body.caseId;
if (body.case_id) summary.caseId = body.case_id;
if (body.status) summary.status = body.status;
// User-related
if (body.user?.id) summary.userId = body.user.id;
if (body.user?.username) summary.username = body.user.username;
if (body.token) summary.tokenGenerated = true;
// Analysis/brace results
if (body.rigoType || body.rigo_classification) {
summary.rigoType = body.rigoType || body.rigo_classification?.type;
}
if (body.cobb_angles || body.cobbAngles) {
const angles = body.cobb_angles || body.cobbAngles;
summary.cobbAngles = { PT: angles.PT, MT: angles.MT, TL: angles.TL };
}
if (body.vertebrae_detected) summary.vertebraeDetected = body.vertebrae_detected;
// Brace outputs
if (body.braces) {
summary.bracesGenerated = {
regular: !!body.braces.regular,
vase: !!body.braces.vase
};
}
if (body.brace) {
summary.braceGenerated = true;
if (body.brace.vertices) summary.braceVertices = body.brace.vertices;
}
// File outputs
if (body.glbUrl || body.stlUrl || body.url) {
summary.filesGenerated = [];
if (body.glbUrl) summary.filesGenerated.push('GLB');
if (body.stlUrl) summary.filesGenerated.push('STL');
if (body.url) summary.outputUrl = body.url.split('/').slice(-2).join('/');
}
// Landmarks
if (body.landmarks) {
summary.landmarksCount = Array.isArray(body.landmarks) ? body.landmarks.length : 'object';
}
// List responses
if (body.cases && Array.isArray(body.cases)) summary.casesCount = body.cases.length;
if (body.users && Array.isArray(body.users)) summary.usersCount = body.users.length;
if (body.entries && Array.isArray(body.entries)) summary.entriesCount = body.entries.length;
if (body.requests && Array.isArray(body.requests)) summary.requestsCount = body.requests.length;
if (body.total !== undefined) summary.total = body.total;
// Body scan
if (body.body_scan_url) summary.bodyScanUploaded = true;
if (body.measurements || body.body_measurements) {
summary.measurementsExtracted = true;
}
// Error responses
if (statusCode >= 400) {
summary.errorCode = statusCode;
}
return Object.keys(summary).length > 0 ? summary : null;
}
/**
* Get route pattern from request (e.g., /api/cases/:caseId)
*/
function getRoutePattern(req) {
// Express stores the matched route in req.route
if (req.route && req.route.path) {
return req.baseUrl + req.route.path;
}
// Fallback: replace common ID patterns
return req.path
.replace(/\/case-[\w-]+/g, '/:caseId')
.replace(/\/\d+/g, '/:id');
}
// Logs all API requests for the activity page
app.use('/api', (req, res, next) => {
const startTime = Date.now();
// Capture original functions
const originalEnd = res.end;
const originalJson = res.json;
let responseBody = null;
let responseBodySize = 0;
// Override res.json to capture response body
res.json = function(body) {
responseBody = body;
if (body) {
try {
responseBodySize = JSON.stringify(body).length;
} catch (e) { /* ignore */ }
}
return originalJson.call(this, body);
};
// Override res.end to log the request after it completes
res.end = function(chunk, encoding) {
const responseTime = Date.now() - startTime;
// Calculate request body size
let requestBodySize = 0;
if (req.body && Object.keys(req.body).length > 0) {
try {
requestBodySize = JSON.stringify(req.body).length;
} catch (e) { /* ignore */ }
}
// Get user info from req.user (set by authMiddleware)
let userId = req.user?.id || null;
let username = req.user?.username || null;
// Skip logging for health check and static files to reduce noise
const skipPaths = ['/api/health', '/api/favicon.ico'];
const shouldLog = !skipPaths.includes(req.path) && !req.path.startsWith('/files');
if (shouldLog) {
// Log asynchronously to not block response
setImmediate(() => {
try {
db.logApiRequest({
userId,
username,
method: req.method,
path: req.path,
routePattern: getRoutePattern(req),
queryParams: Object.keys(req.query).length > 0 ? req.query : null,
requestParams: sanitizeParams(req.body),
fileUploads: extractFileInfo(req),
statusCode: res.statusCode,
responseTimeMs: responseTime,
responseSummary: extractResponseSummary(responseBody, res.statusCode),
ipAddress: req.ip || req.connection?.remoteAddress,
userAgent: req.get('User-Agent'),
requestBodySize,
responseBodySize
});
} catch (e) {
console.error('Failed to log API request:', e.message);
}
});
}
return originalEnd.call(this, chunk, encoding);
};
next();
});
// File upload configuration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
@@ -155,10 +388,21 @@ app.post('/api/cases', (req, res) => {
/**
* List all cases
* GET /api/cases
* Query params:
* - includeArchived: boolean - Include archived cases (admin only)
* - archivedOnly: boolean - Show only archived cases (admin only)
*/
app.get('/api/cases', (req, res) => {
try {
const cases = db.listCases();
const { includeArchived, archivedOnly } = req.query;
// Parse boolean query params
const options = {
includeArchived: includeArchived === 'true',
archivedOnly: archivedOnly === 'true'
};
const cases = db.listCases(options);
res.json(cases);
} catch (err) {
console.error('List cases error:', err);
@@ -1497,28 +1741,79 @@ app.post('/api/cases/:caseId/skip-body-scan', (req, res) => {
// ==============================================
/**
* Delete case
* DELETE /api/cases/:caseId
* Archive case (soft delete - keeps all files)
* POST /api/cases/:caseId/archive
*/
app.delete('/api/cases/:caseId', (req, res) => {
app.post('/api/cases/:caseId/archive', authMiddleware, (req, res) => {
try {
const { caseId } = req.params;
// Delete from database
db.deleteCase(caseId);
// Delete files
const uploadDir = path.join(UPLOADS_DIR, caseId);
const outputDir = path.join(OUTPUTS_DIR, caseId);
if (fs.existsSync(uploadDir)) {
fs.rmSync(uploadDir, { recursive: true });
}
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
const caseData = db.getCase(caseId);
if (!caseData) {
return res.status(404).json({ message: 'Case not found' });
}
res.json({ caseId, deleted: true });
// Archive the case (soft delete - no files are deleted)
db.archiveCase(caseId);
// Log the archive action
db.logAudit(req.user?.id, 'case_archived', 'brace_cases', caseId, null, { archived: true });
res.json({ caseId, archived: true, message: 'Case archived successfully' });
} catch (err) {
console.error('Archive case error:', err);
res.status(500).json({ message: 'Failed to archive case', error: err.message });
}
});
/**
* Unarchive case (restore)
* POST /api/cases/:caseId/unarchive
*/
app.post('/api/cases/:caseId/unarchive', authMiddleware, (req, res) => {
try {
const { caseId } = req.params;
const caseData = db.getCase(caseId);
if (!caseData) {
return res.status(404).json({ message: 'Case not found' });
}
// Unarchive the case
db.unarchiveCase(caseId);
// Log the unarchive action
db.logAudit(req.user?.id, 'case_unarchived', 'brace_cases', caseId, { archived: true }, { archived: false });
res.json({ caseId, archived: false, message: 'Case restored successfully' });
} catch (err) {
console.error('Unarchive case error:', err);
res.status(500).json({ message: 'Failed to unarchive case', error: err.message });
}
});
/**
* Delete case - DEPRECATED: Use archive instead
* DELETE /api/cases/:caseId
* This endpoint now archives instead of deleting to preserve data
*/
app.delete('/api/cases/:caseId', authMiddleware, (req, res) => {
try {
const { caseId } = req.params;
const caseData = db.getCase(caseId);
if (!caseData) {
return res.status(404).json({ message: 'Case not found' });
}
// Archive instead of delete (preserves all files)
db.archiveCase(caseId);
// Log the archive action
db.logAudit(req.user?.id, 'case_archived', 'brace_cases', caseId, null, { archived: true });
// Return deleted: true for backwards compatibility
res.json({ caseId, deleted: true, archived: true, message: 'Case archived (files preserved)' });
} catch (err) {
console.error('Delete case error:', err);
res.status(500).json({ message: 'Failed to delete case', error: err.message });
@@ -1613,6 +1908,249 @@ app.get('/api/cases/:caseId/assets', (req, res) => {
}
});
// ============================================
// PATIENT API
// ============================================
/**
* Create a new patient
* POST /api/patients
*/
app.post('/api/patients', authMiddleware, (req, res) => {
try {
const {
mrn, firstName, lastName, dateOfBirth, gender,
email, phone, address, diagnosis, curveType,
medicalHistory, referringPhysician, insuranceInfo, notes
} = req.body;
if (!firstName || !lastName) {
return res.status(400).json({ message: 'First name and last name are required' });
}
const patient = db.createPatient({
mrn,
firstName,
lastName,
dateOfBirth,
gender,
email,
phone,
address,
diagnosis,
curveType,
medicalHistory,
referringPhysician,
insuranceInfo,
notes,
createdBy: req.user?.id
});
db.logAudit(req.user?.id, 'create_patient', 'patient', patient.id.toString(),
{ firstName, lastName, mrn }, req.ip);
res.status(201).json({ patient });
} catch (err) {
console.error('Create patient error:', err);
res.status(500).json({ message: 'Failed to create patient', error: err.message });
}
});
/**
* List patients
* GET /api/patients
*/
app.get('/api/patients', authMiddleware, (req, res) => {
try {
const { search, isActive, limit = 50, offset = 0, sortBy, sortOrder } = req.query;
const result = db.listPatients({
search,
isActive: isActive === 'false' ? false : (isActive === 'all' ? null : true),
limit: parseInt(limit),
offset: parseInt(offset),
sortBy,
sortOrder
});
res.json(result);
} catch (err) {
console.error('List patients error:', err);
res.status(500).json({ message: 'Failed to list patients', error: err.message });
}
});
/**
* Get patient by ID
* GET /api/patients/:patientId
*/
app.get('/api/patients/:patientId', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const { includeArchivedCases } = req.query;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
// Get patient's cases (filter archived unless explicitly requested)
const cases = db.getPatientCases(parseInt(patientId), {
includeArchived: includeArchivedCases === 'true'
});
res.json({ patient, cases });
} catch (err) {
console.error('Get patient error:', err);
res.status(500).json({ message: 'Failed to get patient', error: err.message });
}
});
/**
* Update patient
* PUT /api/patients/:patientId
*/
app.put('/api/patients/:patientId', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
const updateData = req.body;
db.updatePatient(parseInt(patientId), updateData);
db.logAudit(req.user?.id, 'update_patient', 'patient', patientId, updateData, req.ip);
const updatedPatient = db.getPatient(parseInt(patientId));
res.json({ patient: updatedPatient });
} catch (err) {
console.error('Update patient error:', err);
res.status(500).json({ message: 'Failed to update patient', error: err.message });
}
});
/**
* Archive patient (soft delete - preserves all data)
* POST /api/patients/:patientId/archive
*/
app.post('/api/patients/:patientId/archive', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
db.archivePatient(parseInt(patientId));
db.logAudit(req.user?.id, 'patient_archived', 'patient', patientId,
{ firstName: patient.first_name, lastName: patient.last_name }, { archived: true });
res.json({ patientId: parseInt(patientId), archived: true, message: 'Patient archived successfully' });
} catch (err) {
console.error('Archive patient error:', err);
res.status(500).json({ message: 'Failed to archive patient', error: err.message });
}
});
/**
* Unarchive patient (restore)
* POST /api/patients/:patientId/unarchive
*/
app.post('/api/patients/:patientId/unarchive', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
db.unarchivePatient(parseInt(patientId));
db.logAudit(req.user?.id, 'patient_unarchived', 'patient', patientId,
{ archived: true }, { firstName: patient.first_name, lastName: patient.last_name, archived: false });
res.json({ patientId: parseInt(patientId), archived: false, message: 'Patient restored successfully' });
} catch (err) {
console.error('Unarchive patient error:', err);
res.status(500).json({ message: 'Failed to unarchive patient', error: err.message });
}
});
/**
* Delete patient - DEPRECATED: Use archive instead
* DELETE /api/patients/:patientId
* This endpoint now archives instead of deleting to preserve data
*/
app.delete('/api/patients/:patientId', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
// Archive instead of delete (preserves all data)
db.archivePatient(parseInt(patientId));
db.logAudit(req.user?.id, 'patient_archived', 'patient', patientId,
{ firstName: patient.first_name, lastName: patient.last_name }, { archived: true });
res.json({ message: 'Patient archived successfully', archived: true });
} catch (err) {
console.error('Delete patient error:', err);
res.status(500).json({ message: 'Failed to archive patient', error: err.message });
}
});
/**
* Create a case for a patient
* POST /api/patients/:patientId/cases
*/
app.post('/api/patients/:patientId/cases', authMiddleware, (req, res) => {
try {
const { patientId } = req.params;
const { notes, visitDate } = req.body;
const patient = db.getPatient(parseInt(patientId));
if (!patient) {
return res.status(404).json({ message: 'Patient not found' });
}
const caseId = `case-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const result = db.createCase(caseId, 'braceflow', notes, parseInt(patientId), visitDate);
db.logAudit(req.user?.id, 'create_case', 'case', caseId,
{ patientId, patientName: `${patient.first_name} ${patient.last_name}` }, req.ip);
res.status(201).json(result);
} catch (err) {
console.error('Create patient case error:', err);
res.status(500).json({ message: 'Failed to create case', error: err.message });
}
});
/**
* Get patient statistics
* GET /api/patients/stats
*/
app.get('/api/patients-stats', authMiddleware, (req, res) => {
try {
const stats = db.getPatientStats();
res.json({ stats });
} catch (err) {
console.error('Get patient stats error:', err);
res.status(500).json({ message: 'Failed to get patient stats', error: err.message });
}
});
// ============================================
// AUTHENTICATION API
// ============================================
@@ -1899,7 +2437,7 @@ app.delete('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, re
*/
app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => {
try {
const { status, createdBy, search, limit = 50, offset = 0, sortBy, sortOrder } = req.query;
const { status, createdBy, search, limit = 50, offset = 0, sortBy, sortOrder, includeArchived, archivedOnly } = req.query;
const result = db.listCasesFiltered({
status,
@@ -1908,7 +2446,9 @@ app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => {
limit: parseInt(limit),
offset: parseInt(offset),
sortBy,
sortOrder
sortOrder,
includeArchived: includeArchived === 'true',
archivedOnly: archivedOnly === 'true'
});
res.json(result);
@@ -2018,6 +2558,106 @@ app.get('/api/admin/audit-log', authMiddleware, adminMiddleware, (req, res) => {
}
});
// ============================================
// ADMIN API - API REQUEST ACTIVITY LOG
// ============================================
/**
* Get API request logs (admin only)
* GET /api/admin/activity
*/
app.get('/api/admin/activity', authMiddleware, adminMiddleware, (req, res) => {
try {
const {
userId,
username,
method,
path,
statusCode,
statusCategory, // '2xx', '4xx', '5xx'
startDate,
endDate,
limit = 100,
offset = 0
} = req.query;
const options = {
userId: userId ? parseInt(userId) : undefined,
username,
method,
path,
statusCode: statusCode ? parseInt(statusCode) : undefined,
startDate,
endDate,
limit: parseInt(limit),
offset: parseInt(offset)
};
// Handle status category filter
if (statusCategory === '2xx') {
options.minStatusCode = 200;
options.maxStatusCode = 300;
} else if (statusCategory === '3xx') {
options.minStatusCode = 300;
options.maxStatusCode = 400;
} else if (statusCategory === '4xx') {
options.minStatusCode = 400;
options.maxStatusCode = 500;
} else if (statusCategory === '5xx') {
options.minStatusCode = 500;
options.maxStatusCode = 600;
}
const result = db.getApiRequests(options);
res.json(result);
} catch (err) {
console.error('Get API activity error:', err);
res.status(500).json({ message: 'Failed to get API activity', error: err.message });
}
});
/**
* Get API request statistics (admin only)
* GET /api/admin/activity/stats
*/
app.get('/api/admin/activity/stats', authMiddleware, adminMiddleware, (req, res) => {
try {
const { startDate, endDate } = req.query;
const stats = db.getApiRequestStats({
startDate,
endDate
});
res.json({ stats });
} catch (err) {
console.error('Get API activity stats error:', err);
res.status(500).json({ message: 'Failed to get API activity stats', error: err.message });
}
});
/**
* Cleanup old API request logs (admin only)
* DELETE /api/admin/activity/cleanup
*/
app.delete('/api/admin/activity/cleanup', authMiddleware, adminMiddleware, (req, res) => {
try {
const { daysToKeep = 30 } = req.query;
const result = db.cleanupOldApiRequests(parseInt(daysToKeep));
db.logAudit(req.user.id, 'cleanup_api_logs', 'system', null, { daysToKeep, deletedCount: result.changes }, req.ip);
res.json({
message: `Cleaned up API request logs older than ${daysToKeep} days`,
deletedCount: result.changes
});
} catch (err) {
console.error('Cleanup API activity error:', err);
res.status(500).json({ message: 'Failed to cleanup API activity', error: err.message });
}
});
// ============================================
// Start server
// ============================================
@@ -2048,6 +2688,15 @@ app.listen(PORT, () => {
console.log(' DELETE /api/cases/:id Delete case');
console.log(' GET /api/cases/:id/assets Get files');
console.log('');
console.log('Patient Endpoints:');
console.log(' POST /api/patients Create patient');
console.log(' GET /api/patients List patients');
console.log(' GET /api/patients/:id Get patient');
console.log(' PUT /api/patients/:id Update patient');
console.log(' DELETE /api/patients/:id Delete patient');
console.log(' POST /api/patients/:id/cases Create case for patient');
console.log(' GET /api/patients-stats Get patient statistics');
console.log('');
console.log('Auth Endpoints:');
console.log(' POST /api/auth/login Login');
console.log(' POST /api/auth/logout Logout');
@@ -2061,5 +2710,8 @@ app.listen(PORT, () => {
console.log(' GET /api/admin/cases List cases (filtered)');
console.log(' GET /api/admin/analytics/dashboard Get dashboard stats');
console.log(' GET /api/admin/audit-log Get audit log');
console.log(' GET /api/admin/activity Get API request logs');
console.log(' GET /api/admin/activity/stats Get API activity stats');
console.log(' DELETE /api/admin/activity/cleanup Cleanup old API logs');
console.log('');
});