/** * BraceFlow DEV API Server * Express server that replaces AWS Lambda functions for local development */ import express from 'express'; import cors from 'cors'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; // Note: Using undici for HTTP requests and FormData (native fetch + FormData) // Do NOT import 'form-data' package - it conflicts with native FormData import undici from 'undici'; const { fetch: undiciFetch, FormData: UndiciFormData, File: UndiciFile } = undici; import db from './db/sqlite.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); // Configuration const PORT = process.env.PORT || 3001; const BRACE_GENERATOR_URL = process.env.BRACE_GENERATOR_URL || 'http://localhost:8001'; // Use DATA_DIR from env (Docker) or local path (dev) const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); const OUTPUTS_DIR = path.join(DATA_DIR, 'outputs'); // PUBLIC_URL for constructing URLs in responses // In production, this should be empty to use relative URLs // In local dev, set to http://localhost:3001 const PUBLIC_URL = process.env.PUBLIC_URL || ''; /** * Get base URL for constructing file/API URLs * In production: returns empty string (uses relative URLs) * In local dev: returns http://localhost:PORT */ function getBaseUrl(req) { if (PUBLIC_URL) return PUBLIC_URL; // In local development, return absolute URL with port // This is needed for CORS when frontend runs on different port (e.g., 5173) if (process.env.NODE_ENV !== 'production' && req) { const protocol = req.protocol || 'http'; const host = req.get('host') || `localhost:${PORT}`; return `${protocol}://${host}`; } // In production behind proxy, use relative URLs return ''; } // Ensure directories exist [DATA_DIR, UPLOADS_DIR, OUTPUTS_DIR].forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); // Middleware - CORS: Allow ALL origins, methods, headers app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin'], credentials: false, maxAge: 86400 })); // Handle preflight requests for all routes app.options('*', cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Static file serving for outputs with proper MIME types for 3D files const customMimeTypes = { '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.stl': 'application/octet-stream', '.obj': 'text/plain', '.ply': 'application/octet-stream' }; app.use('/files', express.static(DATA_DIR, { setHeaders: (res, filePath) => { const ext = path.extname(filePath).toLowerCase(); if (customMimeTypes[ext]) { res.setHeader('Content-Type', customMimeTypes[ext]); } } })); // ============================================ // 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) => { const caseId = req.params.caseId || req.body.caseId || 'temp'; const caseDir = path.join(UPLOADS_DIR, caseId); if (!fs.existsSync(caseDir)) { fs.mkdirSync(caseDir, { recursive: true }); } cb(null, caseDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `xray${ext}`); } }); const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } }); // ============================================ // API Routes // ============================================ /** * Health check */ app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'braceflow-api-dev', timestamp: new Date().toISOString() }); }); /** * Create a new case * POST /api/cases */ app.post('/api/cases', (req, res) => { try { const { caseType = 'braceflow', notes = null } = req.body; // Generate case ID const yyyymmdd = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const rand = crypto.randomBytes(4).toString('hex'); const caseId = `case-${yyyymmdd}-${rand}`; const result = db.createCase(caseId, caseType, notes); // Create case directories const caseUploadDir = path.join(UPLOADS_DIR, caseId); const caseOutputDir = path.join(OUTPUTS_DIR, caseId); fs.mkdirSync(caseUploadDir, { recursive: true }); fs.mkdirSync(caseOutputDir, { recursive: true }); res.status(201).json(result); } catch (err) { console.error('Create case error:', err); res.status(500).json({ message: 'Failed to create case', error: err.message }); } }); /** * 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 { 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); res.status(500).json({ message: 'Failed to list cases', error: err.message }); } }); /** * Get case by ID * GET /api/cases/:caseId */ app.get('/api/cases/:caseId', (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } const baseUrl = getBaseUrl(req); // Add xray_url if file exists const caseUploadDir = path.join(UPLOADS_DIR, caseId); if (fs.existsSync(caseUploadDir)) { const files = fs.readdirSync(caseUploadDir); const xrayFile = files.find(f => f.startsWith('xray')); if (xrayFile) { caseData.xray_url = `${baseUrl}/files/uploads/${caseId}/${xrayFile}`; } } // Add visualization_url if file exists const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); if (fs.existsSync(vizPath)) { caseData.visualization_url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; } // Add landmarks_json_url if file exists const landmarksPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); if (fs.existsSync(landmarksPath)) { caseData.landmarks_json_url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; } res.json(caseData); } catch (err) { console.error('Get case error:', err); res.status(500).json({ message: 'Failed to get case', error: err.message }); } }); /** * Get case status * GET /api/cases/:caseId/status */ app.get('/api/cases/:caseId/status', (req, res) => { try { const caseData = db.getCase(req.params.caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } res.json(caseData); } catch (err) { console.error('Get case status error:', err); res.status(500).json({ message: 'Failed to get case status', error: err.message }); } }); /** * Get upload URL (for S3 compatibility - returns direct upload endpoint) * POST /api/cases/:caseId/upload-url */ app.post('/api/cases/:caseId/upload-url', (req, res) => { try { const { caseId } = req.params; const { filename, contentType } = req.body; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // For DEV, return a URL that points to our direct upload endpoint // The frontend will PUT to this URL with the file const baseUrl = getBaseUrl(req); const uploadUrl = `${baseUrl}/api/cases/${caseId}/upload-direct`; const s3Key = `cases/${caseId}/input/${filename || 'ap.jpg'}`; res.json({ url: uploadUrl, s3Key: s3Key }); } catch (err) { console.error('Get upload URL error:', err); res.status(500).json({ message: 'Failed to get upload URL', error: err.message }); } }); /** * Direct upload (PUT) - for presigned URL compatibility * PUT /api/cases/:caseId/upload-direct */ app.put('/api/cases/:caseId/upload-direct', express.raw({ type: '*/*', limit: '50mb' }), (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Save the raw body as a file const caseUploadDir = path.join(UPLOADS_DIR, caseId); if (!fs.existsSync(caseUploadDir)) { fs.mkdirSync(caseUploadDir, { recursive: true }); } // Determine extension from content-type const contentType = req.headers['content-type'] || 'image/jpeg'; const ext = contentType.includes('png') ? '.png' : '.jpg'; const filename = `xray${ext}`; const filePath = path.join(caseUploadDir, filename); fs.writeFileSync(filePath, req.body); res.status(200).send('OK'); } catch (err) { console.error('Direct upload error:', err); res.status(500).json({ message: 'Failed to upload file', error: err.message }); } }); /** * Upload X-ray image (multipart form) * POST /api/cases/:caseId/upload */ app.post('/api/cases/:caseId/upload', upload.single('file'), (req, res) => { try { const { caseId } = req.params; if (!req.file) { return res.status(400).json({ message: 'No file uploaded' }); } const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } const filePath = `/files/uploads/${caseId}/${req.file.filename}`; res.json({ caseId, filename: req.file.filename, path: filePath, size: req.file.size }); } catch (err) { console.error('Upload error:', err); res.status(500).json({ message: 'Failed to upload file', error: err.message }); } }); /** * Detect landmarks (Stage 1) * POST /api/cases/:caseId/detect-landmarks */ app.post('/api/cases/:caseId/detect-landmarks', async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Find the X-ray file const caseUploadDir = path.join(UPLOADS_DIR, caseId); const files = fs.readdirSync(caseUploadDir); const xrayFile = files.find(f => f.startsWith('xray')); if (!xrayFile) { return res.status(400).json({ message: 'No X-ray image found. Please upload first.' }); } const xrayPath = path.join(caseUploadDir, xrayFile); // Update status db.updateCaseStatus(caseId, 'running', 'LandmarkDetection'); db.updateStepStatus(caseId, 'LandmarkDetection', 'running'); // Call brace generator for landmark detection only // Use undici FormData with File for proper multipart handling const formData = new UndiciFormData(); const fileBuffer = fs.readFileSync(xrayPath); // Use File instead of Blob for proper filename handling const file = new File([fileBuffer], xrayFile, { type: 'image/jpeg' }); formData.append('file', file); formData.append('case_id', caseId); formData.append('detect_only', 'true'); console.log(`Calling brace generator: ${BRACE_GENERATOR_URL}/detect-landmarks`); const response = await undiciFetch(`${BRACE_GENERATOR_URL}/detect-landmarks`, { method: 'POST', body: formData }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Brace generator error: ${errorText}`); } const result = await response.json(); const baseUrl = getBaseUrl(req); // Save visualization to outputs if (result.visualization_path) { const vizResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${path.basename(result.visualization_path)}`); if (vizResponse.ok) { const vizBuffer = Buffer.from(await vizResponse.arrayBuffer()); const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); fs.mkdirSync(path.dirname(vizPath), { recursive: true }); fs.writeFileSync(vizPath, vizBuffer); result.visualization_url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; } } // Save JSON to outputs const jsonPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2)); result.json_url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; // Save to database db.saveLandmarks(caseId, result); db.updateStepStatus(caseId, 'LandmarkDetection', 'done'); res.json({ caseId, status: 'landmarks_detected', ...result }); } catch (err) { console.error('Detect landmarks error:', err); db.updateStepStatus(caseId, 'LandmarkDetection', 'failed', err.message); db.updateCaseStatus(caseId, 'failed'); res.status(500).json({ message: 'Failed to detect landmarks', error: err.message }); } }); /** * Save/update landmarks (manual edit) * PUT /api/cases/:caseId/landmarks */ app.put('/api/cases/:caseId/landmarks', (req, res) => { try { const { caseId } = req.params; const { landmarks_data } = req.body; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } db.saveLandmarks(caseId, landmarks_data); // Save updated JSON const jsonPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); fs.writeFileSync(jsonPath, JSON.stringify(landmarks_data, null, 2)); res.json({ caseId, status: 'landmarks_updated' }); } catch (err) { console.error('Update landmarks error:', err); res.status(500).json({ message: 'Failed to update landmarks', error: err.message }); } }); /** * Approve landmarks and move to analysis (Stage 2) * POST /api/cases/:caseId/approve-landmarks */ app.post('/api/cases/:caseId/approve-landmarks', (req, res) => { try { const { caseId } = req.params; const { updated_landmarks } = req.body; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } db.approveLandmarks(caseId, updated_landmarks); db.updateStepStatus(caseId, 'LandmarkApproval', 'done'); res.json({ caseId, status: 'landmarks_approved', next_step: 'SpineAnalysis' }); } catch (err) { console.error('Approve landmarks error:', err); res.status(500).json({ message: 'Failed to approve landmarks', error: err.message }); } }); /** * Recalculate spine analysis from landmarks * POST /api/cases/:caseId/recalculate */ app.post('/api/cases/:caseId/recalculate', async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } if (!caseData.landmarks_data) { return res.status(400).json({ message: 'No landmarks data found' }); } db.updateStepStatus(caseId, 'SpineAnalysis', 'running'); // Call brace generator to recalculate const response = await fetch(`${BRACE_GENERATOR_URL}/recalculate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ case_id: caseId, landmarks: caseData.landmarks_data }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Recalculation error: ${errorText}`); } const result = await response.json(); // Save analysis data separately (for Stage 2 display) db.saveAnalysisData(caseId, result); // Also update landmarks_data with new Cobb angles so it persists const updatedLandmarks = { ...caseData.landmarks_data, cobb_angles: result.cobb_angles, rigo_classification: result.rigo_classification, curve_type: result.curve_type }; db.saveLandmarks(caseId, updatedLandmarks); db.updateStepStatus(caseId, 'SpineAnalysis', 'done'); // Save JSON to outputs const caseOutputDir = path.join(OUTPUTS_DIR, caseId); fs.mkdirSync(caseOutputDir, { recursive: true }); const jsonPath = path.join(caseOutputDir, 'analysis.json'); fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2)); res.json({ caseId, status: 'analysis_complete', ...result }); } catch (err) { console.error('Recalculate error:', err); db.updateStepStatus(caseId, 'SpineAnalysis', 'failed', err.message); res.status(500).json({ message: 'Failed to recalculate', error: err.message }); } }); /** * Generate brace (Stage 4) * POST /api/cases/:caseId/generate-brace * * If body scan is available, uses EXPERIMENT_10 (body-fitted brace) * Otherwise, uses EXPERIMENT_3 (X-ray only adaptive brace) */ app.post('/api/cases/:caseId/generate-brace', async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Find the X-ray file const caseUploadDir = path.join(UPLOADS_DIR, caseId); const files = fs.readdirSync(caseUploadDir); const xrayFile = files.find(f => f.startsWith('xray')); if (!xrayFile) { return res.status(400).json({ message: 'No X-ray image found' }); } const xrayPath = path.join(caseUploadDir, xrayFile); // Check if body scan should be used (based on database state, not just file existence) // This ensures "skip body scan" works correctly even if a file was uploaded earlier const hasBodyScanInDb = caseData.body_scan_path && caseData.body_scan_url; const bodyScanFile = hasBodyScanInDb ? files.find(f => f.startsWith('body_scan')) : null; const hasBodyScan = !!bodyScanFile; const bodyScanPath = hasBodyScan ? path.join(caseUploadDir, bodyScanFile) : null; db.updateCaseStatus(caseId, 'processing_brace', 'BraceGeneration'); db.updateStepStatus(caseId, 'BraceGeneration', 'running'); let result; if (hasBodyScan) { // Use EXPERIMENT_10: Body-fitted brace generation console.log(`Calling brace generator with body scan: ${BRACE_GENERATOR_URL}/generate-with-body`); // Use undici FormData with File for proper multipart handling const formData = new UndiciFormData(); // Add X-ray file const xrayBuffer = fs.readFileSync(xrayPath); const xrayFileObj = new File([xrayBuffer], xrayFile, { type: 'image/jpeg' }); formData.append('xray_file', xrayFileObj); // Add body scan file const bodyScanBuffer = fs.readFileSync(bodyScanPath); const bodyScanFileObj = new File([bodyScanBuffer], bodyScanFile, { type: 'application/octet-stream' }); formData.append('body_scan_file', bodyScanFileObj); formData.append('case_id', caseId); // Include landmarks if manually edited if (caseData.landmarks_data) { formData.append('landmarks_json', JSON.stringify(caseData.landmarks_data)); } // Add clearance from body scan metadata if available const clearance = caseData.body_scan_metadata?.clearance_mm || 8.0; formData.append('clearance_mm', clearance.toString()); const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-with-body`, { method: 'POST', body: formData }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Body-fitted brace generator error: ${errorText}`); } result = await response.json(); result.generation_mode = 'body_fitted'; } else { // Use EXPERIMENT_3: X-ray only adaptive brace console.log(`Calling brace generator (X-ray only): ${BRACE_GENERATOR_URL}/analyze/upload`); // Use undici FormData with File for proper multipart handling const formData = new UndiciFormData(); const xrayBuffer = fs.readFileSync(xrayPath); const xrayFileObj = new File([xrayBuffer], xrayFile, { type: 'image/jpeg' }); formData.append('file', xrayFileObj); formData.append('case_id', caseId); formData.append('experiment', 'experiment_3'); // Include landmarks if manually edited if (caseData.landmarks_data) { formData.append('landmarks_json', JSON.stringify(caseData.landmarks_data)); } const response = await undiciFetch(`${BRACE_GENERATOR_URL}/analyze/upload`, { method: 'POST', body: formData }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Brace generator error: ${errorText}`); } result = await response.json(); result.generation_mode = 'xray_only'; } // Download output files from brace generator const baseUrl = getBaseUrl(req); const outputFiles = {}; const caseOutputDir = path.join(OUTPUTS_DIR, caseId); fs.mkdirSync(caseOutputDir, { recursive: true }); for (const [key, serverPath] of Object.entries(result.outputs || {})) { if (!serverPath) continue; const filename = path.basename(serverPath); try { const fileResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${filename}`); if (fileResponse.ok) { const buffer = Buffer.from(await fileResponse.arrayBuffer()); const localPath = path.join(caseOutputDir, filename); fs.writeFileSync(localPath, buffer); outputFiles[key] = `${baseUrl}/files/outputs/${caseId}/${filename}`; } } catch (e) { console.error(`Failed to download ${key}:`, e.message); } } // Also generate BOTH GLB braces (regular + vase) for the dual viewer const rigoType = result.rigo_classification?.type || result.rigo_type || 'A1'; const cobbAngles = result.cobb_angles || {}; console.log(`Generating both GLB braces: ${rigoType}`); try { const bothFormData = new UndiciFormData(); bothFormData.append('rigo_type', rigoType); bothFormData.append('case_id', caseId); bothFormData.append('cobb_pt', String(cobbAngles.PT || 0)); bothFormData.append('cobb_mt', String(cobbAngles.MT || 0)); bothFormData.append('cobb_tl', String(cobbAngles.TL || 0)); // Add body scan if available if (hasBodyScan && bodyScanPath && fs.existsSync(bodyScanPath)) { const bodyScanBuffer = fs.readFileSync(bodyScanPath); const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); bothFormData.append('body_scan', bodyScanFileObj); } const bothResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-both-braces`, { method: 'POST', body: bothFormData }); if (bothResponse.ok) { const bothResult = await bothResponse.json(); // Download both brace files via HTTP (they're in different container) const braces = {}; for (const [braceType, braceData] of Object.entries(bothResult.braces || {})) { if (braceData.error) { braces[braceType] = { error: braceData.error }; continue; } const outputs = {}; // Download GLB via HTTP if (braceData.outputs?.glb) { const glbFilename = path.basename(braceData.outputs.glb); const glbName = `${caseId}_${rigoType}_${braceType}.glb`; try { const glbResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${glbFilename}`); if (glbResponse.ok) { const buffer = Buffer.from(await glbResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, glbName), buffer); outputs.glb = `${baseUrl}/files/outputs/${caseId}/${glbName}`; } } catch (e) { console.warn(`Failed to download ${braceType} GLB:`, e.message); } } // Download STL via HTTP if (braceData.outputs?.stl) { const stlFilename = path.basename(braceData.outputs.stl); const stlName = `${caseId}_${rigoType}_${braceType}.stl`; try { const stlResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${stlFilename}`); if (stlResponse.ok) { const buffer = Buffer.from(await stlResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, stlName), buffer); outputs.stl = `${baseUrl}/files/outputs/${caseId}/${stlName}`; } } catch (e) { console.warn(`Failed to download ${braceType} STL:`, e.message); } } // Download markers JSON via HTTP if (braceData.outputs?.json) { const jsonFilename = path.basename(braceData.outputs.json); const jsonName = `${caseId}_${rigoType}_${braceType}_markers.json`; try { const jsonResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${jsonFilename}`); if (jsonResponse.ok) { const buffer = Buffer.from(await jsonResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, jsonName), buffer); outputs.json = `${baseUrl}/files/outputs/${caseId}/${jsonName}`; } } catch (e) { console.warn(`Failed to download ${braceType} markers:`, e.message); } } braces[braceType] = { outputs, markers: braceData.markers, pressureZones: braceData.pressure_zones, meshStats: braceData.mesh_stats }; } // Add both braces to result result.braces = braces; console.log('Both GLB braces generated and downloaded successfully'); } else { console.warn('Failed to generate both braces:', await bothResponse.text()); } } catch (bothErr) { console.warn('Error generating both braces:', bothErr.message); } // Save result to database const braceResult = { ...result, outputs: outputFiles }; // If we have both braces, use saveBothBracesResult if (result.braces) { const rigoType = result.rigo_classification?.type || result.rigo_type || 'A1'; db.saveBothBracesResult(caseId, { braces: result.braces, rigoType: rigoType, cobbAngles: result.cobb_angles, bodyScanUsed: hasBodyScan }); } else { db.saveBraceResult(caseId, braceResult); } db.updateStepStatus(caseId, 'BraceGeneration', 'done'); res.json({ caseId, status: 'brace_generated', ...braceResult }); } catch (err) { console.error('Generate brace error:', err); db.updateStepStatus(caseId, 'BraceGeneration', 'failed', err.message); db.updateCaseStatus(caseId, 'brace_failed'); res.status(500).json({ message: 'Failed to generate brace', error: err.message }); } }); /** * Get brace outputs * GET /api/cases/:caseId/brace-outputs */ app.get('/api/cases/:caseId/brace-outputs', (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } const outputs = caseData.analysis_result?.brace?.outputs || {}; res.json({ caseId, status: caseData.status, outputs }); } catch (err) { console.error('Get brace outputs error:', err); res.status(500).json({ message: 'Failed to get brace outputs', error: err.message }); } }); // ============================================== // GLB BRACE GENERATION WITH MARKERS (NEW) // ============================================== /** * Get available brace templates * GET /api/templates */ app.get('/api/templates', async (req, res) => { try { const response = await fetch(`${BRACE_GENERATOR_URL}/templates`); const data = await response.json(); res.json(data); } catch (err) { console.error('Get templates error:', err); res.status(500).json({ message: 'Failed to get templates', error: err.message }); } }); /** * Get template markers * GET /api/templates/:rigoType/markers */ app.get('/api/templates/:rigoType/markers', async (req, res) => { try { const { rigoType } = req.params; const { template_type = 'regular' } = req.query; const response = await fetch( `${BRACE_GENERATOR_URL}/templates/${rigoType}/markers?template_type=${template_type}` ); const data = await response.json(); if (!response.ok) { return res.status(response.status).json(data); } res.json(data); } catch (err) { console.error('Get template markers error:', err); res.status(500).json({ message: 'Failed to get markers', error: err.message }); } }); /** * Get pressure zone information * GET /api/pressure-zones/:rigoType */ app.get('/api/pressure-zones/:rigoType', async (req, res) => { try { const { rigoType } = req.params; const { template_type = 'regular', cobb_mt = 25, cobb_tl = 15 } = req.query; const response = await fetch( `${BRACE_GENERATOR_URL}/pressure-zones/${rigoType}?template_type=${template_type}&cobb_mt=${cobb_mt}&cobb_tl=${cobb_tl}` ); const data = await response.json(); if (!response.ok) { return res.status(response.status).json(data); } res.json(data); } catch (err) { console.error('Get pressure zones error:', err); res.status(500).json({ message: 'Failed to get pressure zones', error: err.message }); } }); /** * Generate GLB brace with markers * POST /api/cases/:caseId/generate-glb */ app.post('/api/cases/:caseId/generate-glb', upload.single('body_scan'), async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Get analysis data for Cobb angles // analysis_data may already be an object or a JSON string let analysisData = {}; if (caseData.analysis_data) { if (typeof caseData.analysis_data === 'string') { try { analysisData = JSON.parse(caseData.analysis_data); } catch (e) { console.warn('Could not parse analysis_data:', e.message); } } else { analysisData = caseData.analysis_data; } } const cobbAngles = analysisData.cobb_angles || {}; // Get Rigo type from analysis or request body const rigoType = req.body.rigo_type || analysisData.rigo_type || 'A1'; const templateType = req.body.template_type || 'regular'; // Create form data for brace generator (use UndiciFormData for proper compatibility) const formData = new UndiciFormData(); formData.append('rigo_type', rigoType); formData.append('template_type', templateType); formData.append('case_id', caseId); formData.append('cobb_pt', String(cobbAngles.PT || 0)); formData.append('cobb_mt', String(cobbAngles.MT || 0)); formData.append('cobb_tl', String(cobbAngles.TL || 0)); // Add body scan if provided in request or exists in case let bodyScanPath = req.file?.path; if (!bodyScanPath && caseData.body_scan_path) { bodyScanPath = caseData.body_scan_path; } if (bodyScanPath && fs.existsSync(bodyScanPath)) { const bodyScanBuffer = fs.readFileSync(bodyScanPath); const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); formData.append('body_scan', bodyScanFileObj); } // Call brace generator const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-glb`, { method: 'POST', body: formData }); const result = await response.json(); if (!response.ok) { return res.status(response.status).json(result); } // Copy output files to case directory const caseOutputDir = path.join(OUTPUTS_DIR, caseId); if (!fs.existsSync(caseOutputDir)) { fs.mkdirSync(caseOutputDir, { recursive: true }); } const outputs = {}; // Copy GLB if (result.outputs?.glb && fs.existsSync(result.outputs.glb)) { const glbName = `${caseId}_${rigoType}_${templateType}.glb`; const glbDest = path.join(caseOutputDir, glbName); fs.copyFileSync(result.outputs.glb, glbDest); outputs.glb = `/files/outputs/${caseId}/${glbName}`; } // Copy STL if (result.outputs?.stl && fs.existsSync(result.outputs.stl)) { const stlName = `${caseId}_${rigoType}_${templateType}.stl`; const stlDest = path.join(caseOutputDir, stlName); fs.copyFileSync(result.outputs.stl, stlDest); outputs.stl = `/files/outputs/${caseId}/${stlName}`; } // Copy JSON if (result.outputs?.json && fs.existsSync(result.outputs.json)) { const jsonName = `${caseId}_${rigoType}_${templateType}_markers.json`; const jsonDest = path.join(caseOutputDir, jsonName); fs.copyFileSync(result.outputs.json, jsonDest); outputs.json = `/files/outputs/${caseId}/${jsonName}`; } res.json({ caseId, rigoType, templateType, outputs, markers: result.markers, pressureZones: result.pressure_zones, meshStats: result.mesh_stats, bodyFitting: result.body_fitting }); } catch (err) { console.error('Generate GLB error:', err); res.status(500).json({ message: 'Failed to generate GLB brace', error: err.message }); } }); /** * Generate both brace types (regular + vase) for comparison * POST /api/cases/:caseId/generate-both-braces */ app.post('/api/cases/:caseId/generate-both-braces', upload.single('body_scan'), async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Get analysis data for Cobb angles // analysis_data may already be an object or a JSON string let analysisData = {}; if (caseData.analysis_data) { if (typeof caseData.analysis_data === 'string') { try { analysisData = JSON.parse(caseData.analysis_data); } catch (e) { console.warn('Could not parse analysis_data:', e.message); } } else { analysisData = caseData.analysis_data; } } const cobbAngles = analysisData.cobb_angles || {}; // Get Rigo type from analysis or request body const rigoType = req.body.rigo_type || analysisData.rigo_classification?.type || analysisData.rigo_type || 'A1'; console.log('Generating both braces:', { caseId, rigoType, cobbAngles }); // Create form data for brace generator using undici FormData const formData = new UndiciFormData(); formData.append('rigo_type', rigoType); formData.append('case_id', caseId); formData.append('cobb_pt', String(cobbAngles.PT || 0)); formData.append('cobb_mt', String(cobbAngles.MT || 0)); formData.append('cobb_tl', String(cobbAngles.TL || 0)); // Add body scan if provided in request or exists in case let bodyScanPath = req.file?.path; if (!bodyScanPath && caseData.body_scan_path) { bodyScanPath = caseData.body_scan_path; } if (bodyScanPath && fs.existsSync(bodyScanPath)) { const bodyScanBuffer = fs.readFileSync(bodyScanPath); const bodyScanFileObj = new File([bodyScanBuffer], path.basename(bodyScanPath), { type: 'application/octet-stream' }); formData.append('body_scan', bodyScanFileObj); } // Call brace generator using undici fetch const response = await undiciFetch(`${BRACE_GENERATOR_URL}/generate-both-braces`, { method: 'POST', body: formData }); const result = await response.json(); if (!response.ok) { return res.status(response.status).json(result); } // Copy output files to case directory const caseOutputDir = path.join(OUTPUTS_DIR, caseId); if (!fs.existsSync(caseOutputDir)) { fs.mkdirSync(caseOutputDir, { recursive: true }); } const braces = {}; const baseUrl = getBaseUrl(req); // Process both brace types - download files via HTTP since containers are separate for (const [braceType, braceData] of Object.entries(result.braces || {})) { if (braceData.error) { braces[braceType] = { error: braceData.error }; continue; } const outputs = {}; // Download GLB via HTTP if (braceData.outputs?.glb) { const glbFilename = path.basename(braceData.outputs.glb); const glbName = `${caseId}_${rigoType}_${braceType}.glb`; try { const glbResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${glbFilename}`); if (glbResponse.ok) { const buffer = Buffer.from(await glbResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, glbName), buffer); outputs.glb = `/files/outputs/${caseId}/${glbName}`; } } catch (e) { console.warn(`Failed to download ${braceType} GLB:`, e.message); } } // Download STL via HTTP if (braceData.outputs?.stl) { const stlFilename = path.basename(braceData.outputs.stl); const stlName = `${caseId}_${rigoType}_${braceType}.stl`; try { const stlResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${stlFilename}`); if (stlResponse.ok) { const buffer = Buffer.from(await stlResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, stlName), buffer); outputs.stl = `/files/outputs/${caseId}/${stlName}`; } } catch (e) { console.warn(`Failed to download ${braceType} STL:`, e.message); } } // Download markers JSON via HTTP if (braceData.outputs?.json) { const jsonFilename = path.basename(braceData.outputs.json); const jsonName = `${caseId}_${rigoType}_${braceType}_markers.json`; try { const jsonResponse = await fetch(`${BRACE_GENERATOR_URL}/download/${caseId}/${jsonFilename}`); if (jsonResponse.ok) { const buffer = Buffer.from(await jsonResponse.arrayBuffer()); fs.writeFileSync(path.join(caseOutputDir, jsonName), buffer); outputs.json = `/files/outputs/${caseId}/${jsonName}`; } } catch (e) { console.warn(`Failed to download ${braceType} markers:`, e.message); } } braces[braceType] = { outputs, markers: braceData.markers, pressureZones: braceData.pressure_zones, meshStats: braceData.mesh_stats }; } // Build the response const responseData = { caseId, rigoType, cobbAngles, bodyScanUsed: bodyScanPath !== null && bodyScanPath !== undefined, braces }; // Save to database so it persists on refresh db.saveBothBracesResult(caseId, responseData); db.updateStepStatus(caseId, 'BraceGeneration', 'done'); res.json(responseData); } catch (err) { console.error('Generate both braces error:', err); db.updateStepStatus(caseId, 'BraceGeneration', 'failed', err.message); res.status(500).json({ message: 'Failed to generate braces', error: err.message }); } }); /** * Save markers (for brace editor) * PUT /api/cases/:caseId/markers */ app.put('/api/cases/:caseId/markers', (req, res) => { try { const { caseId } = req.params; const { markers_data } = req.body; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } db.saveMarkers(caseId, markers_data); res.json({ caseId, status: 'markers_updated' }); } catch (err) { console.error('Update markers error:', err); res.status(500).json({ message: 'Failed to update markers', error: err.message }); } }); // ============================================== // BODY SCAN ENDPOINTS (NEW - Stage 3) // ============================================== /** * Upload body scan (STL/OBJ/PLY) * POST /api/cases/:caseId/body-scan */ app.post('/api/cases/:caseId/body-scan', upload.single('file'), async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } if (!req.file) { return res.status(400).json({ message: 'No file uploaded' }); } // Validate file type const allowedExtensions = ['.stl', '.obj', '.ply', '.glb', '.gltf']; const ext = path.extname(req.file.originalname).toLowerCase(); if (!allowedExtensions.includes(ext)) { // Clean up uploaded file fs.unlinkSync(req.file.path); return res.status(400).json({ message: `Invalid file type. Allowed: ${allowedExtensions.join(', ')}` }); } // Move file to case uploads directory const caseUploadDir = path.join(UPLOADS_DIR, caseId); fs.mkdirSync(caseUploadDir, { recursive: true }); const destFilename = `body_scan${ext}`; const destPath = path.join(caseUploadDir, destFilename); // Remove existing body scan if any const existingFiles = fs.readdirSync(caseUploadDir).filter(f => f.startsWith('body_scan')); for (const f of existingFiles) { fs.unlinkSync(path.join(caseUploadDir, f)); } // Move uploaded file fs.renameSync(req.file.path, destPath); const baseUrl = getBaseUrl(req); const scanUrl = `${baseUrl}/files/uploads/${caseId}/${destFilename}`; // Try to extract body measurements via brace generator let metadata = { file_format: ext.replace('.', ''), filename: destFilename, uploaded_at: new Date().toISOString() }; try { // Call brace generator to extract body measurements const formData = new UndiciFormData(); const fileBuffer = fs.readFileSync(destPath); const fileObj = new File([fileBuffer], destFilename, { type: 'application/octet-stream' }); formData.append('file', fileObj); const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { method: 'POST', body: formData }); if (measureResponse.ok) { const measurements = await measureResponse.json(); metadata = { ...metadata, ...measurements }; } } catch (e) { console.log('Could not extract body measurements:', e.message); // Continue without measurements } // Save to database db.saveBodyScan(caseId, destPath, scanUrl, metadata); res.json({ caseId, status: 'body_scan_uploaded', body_scan: { path: destPath, url: scanUrl, metadata } }); } catch (err) { console.error('Upload body scan error:', err); res.status(500).json({ message: 'Failed to upload body scan', error: err.message }); } }); /** * Get body scan info * GET /api/cases/:caseId/body-scan * Auto-extracts measurements if missing */ app.get('/api/cases/:caseId/body-scan', async (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } if (!caseData.body_scan_path) { return res.json({ caseId, has_body_scan: false, body_scan: null }); } // Ensure URL is absolute (might be stored as relative in older entries) let bodyScanUrl = caseData.body_scan_url; if (bodyScanUrl && !bodyScanUrl.startsWith('http')) { const baseUrl = getBaseUrl(req); bodyScanUrl = baseUrl + bodyScanUrl; } let metadata = caseData.body_scan_metadata || {}; // Auto-extract measurements if missing and file exists if (!metadata.total_height_mm && fs.existsSync(caseData.body_scan_path)) { try { console.log(`Auto-extracting measurements for case ${caseId}`); const formData = new UndiciFormData(); const fileBuffer = fs.readFileSync(caseData.body_scan_path); const filename = path.basename(caseData.body_scan_path); const fileObj = new File([fileBuffer], filename, { type: 'application/octet-stream' }); formData.append('file', fileObj); const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { method: 'POST', body: formData }); if (measureResponse.ok) { const measurements = await measureResponse.json(); metadata = { ...metadata, ...measurements }; // Save updated metadata db.saveBodyScan(caseId, caseData.body_scan_path, caseData.body_scan_url, metadata); console.log(`Measurements extracted and saved for case ${caseId}`); } } catch (e) { console.log(`Could not auto-extract measurements: ${e.message}`); } } res.json({ caseId, has_body_scan: true, body_scan: { path: caseData.body_scan_path, url: bodyScanUrl, metadata } }); } catch (err) { console.error('Get body scan error:', err); res.status(500).json({ message: 'Failed to get body scan', error: err.message }); } }); /** * Re-extract body measurements from existing body scan * POST /api/cases/:caseId/body-scan/refresh-measurements */ app.post('/api/cases/:caseId/body-scan/refresh-measurements', async (req, res) => { const { caseId } = req.params; try { const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } if (!caseData.body_scan_path || !fs.existsSync(caseData.body_scan_path)) { return res.status(400).json({ message: 'No body scan found for this case' }); } // Extract measurements from existing body scan const formData = new UndiciFormData(); const fileBuffer = fs.readFileSync(caseData.body_scan_path); const filename = path.basename(caseData.body_scan_path); const fileObj = new File([fileBuffer], filename, { type: 'application/octet-stream' }); formData.append('file', fileObj); const measureResponse = await undiciFetch(`${BRACE_GENERATOR_URL}/extract-body-measurements`, { method: 'POST', body: formData }); if (!measureResponse.ok) { const errorText = await measureResponse.text(); return res.status(500).json({ message: 'Failed to extract measurements', error: errorText }); } const measurements = await measureResponse.json(); // Merge with existing metadata const updatedMetadata = { ...(caseData.body_scan_metadata || {}), ...measurements }; // Update database db.saveBodyScan(caseId, caseData.body_scan_path, caseData.body_scan_url, updatedMetadata); const baseUrl = getBaseUrl(req); let bodyScanUrl = caseData.body_scan_url; if (bodyScanUrl && !bodyScanUrl.startsWith('http')) { bodyScanUrl = baseUrl + bodyScanUrl; } res.json({ caseId, status: 'measurements_updated', body_scan: { path: caseData.body_scan_path, url: bodyScanUrl, metadata: updatedMetadata } }); } catch (err) { console.error('Refresh measurements error:', err); res.status(500).json({ message: 'Failed to refresh measurements', error: err.message }); } }); /** * Delete body scan (skip body scan stage) * DELETE /api/cases/:caseId/body-scan */ app.delete('/api/cases/:caseId/body-scan', (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Delete the file if it exists if (caseData.body_scan_path && fs.existsSync(caseData.body_scan_path)) { fs.unlinkSync(caseData.body_scan_path); } // Clear from database db.clearBodyScan(caseId); res.json({ caseId, status: 'body_scan_removed', message: 'Body scan removed. Will generate brace from X-ray only.' }); } catch (err) { console.error('Delete body scan error:', err); res.status(500).json({ message: 'Failed to delete body scan', error: err.message }); } }); /** * Skip body scan and proceed to brace generation * POST /api/cases/:caseId/skip-body-scan */ app.post('/api/cases/:caseId/skip-body-scan', (req, res) => { try { const { caseId } = req.params; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } // Clear any existing body scan data so we use X-ray only generation db.clearBodyScan(caseId); // Update step status to skip body scan db.updateStepStatus(caseId, 'BodyScanUpload', 'done'); db.updateCaseStatus(caseId, 'analysis_complete', 'BraceGeneration'); res.json({ caseId, status: 'analysis_complete', message: 'Skipped body scan. Ready for brace generation.' }); } catch (err) { console.error('Skip body scan error:', err); res.status(500).json({ message: 'Failed to skip body scan', error: err.message }); } }); // ============================================== // END BODY SCAN ENDPOINTS // ============================================== /** * Archive case (soft delete - keeps all files) * POST /api/cases/:caseId/archive */ app.post('/api/cases/:caseId/archive', 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 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 }); } }); /** * Get download URL for case assets (S3 presigned URL compatibility) * GET /api/cases/:caseId/download-url?type=xray|landmarks|measurements */ app.get('/api/cases/:caseId/download-url', (req, res) => { try { const { caseId } = req.params; const { type = 'xray', view = 'ap' } = req.query; const caseData = db.getCase(caseId); if (!caseData) { return res.status(404).json({ message: 'Case not found' }); } const baseUrl = getBaseUrl(req); let url = null; if (type === 'xray') { // Find X-ray file const uploadDir = path.join(UPLOADS_DIR, caseId); if (fs.existsSync(uploadDir)) { const files = fs.readdirSync(uploadDir); const xrayFile = files.find(f => f.match(/\.(jpg|jpeg|png)$/i)); if (xrayFile) { url = `${baseUrl}/files/uploads/${caseId}/${xrayFile}`; } } } else if (type === 'landmarks') { const landmarksPath = path.join(OUTPUTS_DIR, caseId, 'landmarks.json'); if (fs.existsSync(landmarksPath)) { url = `${baseUrl}/files/outputs/${caseId}/landmarks.json`; } } else if (type === 'visualization') { const vizPath = path.join(OUTPUTS_DIR, caseId, 'visualization.png'); if (fs.existsSync(vizPath)) { url = `${baseUrl}/files/outputs/${caseId}/visualization.png`; } } if (!url) { return res.status(404).json({ message: `${type} file not found for case` }); } res.json({ url }); } catch (err) { console.error('Get download URL error:', err); res.status(500).json({ message: 'Failed to get download URL', error: err.message }); } }); /** * Get case assets (files) * GET /api/cases/:caseId/assets */ app.get('/api/cases/:caseId/assets', (req, res) => { try { const { caseId } = req.params; const baseUrl = getBaseUrl(req); const assets = { uploads: [], outputs: [] }; const uploadDir = path.join(UPLOADS_DIR, caseId); const outputDir = path.join(OUTPUTS_DIR, caseId); if (fs.existsSync(uploadDir)) { assets.uploads = fs.readdirSync(uploadDir).map(f => ({ filename: f, url: `${baseUrl}/files/uploads/${caseId}/${f}` })); } if (fs.existsSync(outputDir)) { assets.outputs = fs.readdirSync(outputDir).map(f => ({ filename: f, url: `${baseUrl}/files/outputs/${caseId}/${f}` })); } res.json({ caseId, assets }); } catch (err) { console.error('Get assets error:', err); res.status(500).json({ message: 'Failed to get assets', error: err.message }); } }); // ============================================ // 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 // ============================================ /** * Login * POST /api/auth/login */ app.post('/api/auth/login', (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ message: 'Username and password are required' }); } const user = db.getUserByUsername(username); if (!user) { return res.status(401).json({ message: 'Invalid username or password' }); } if (!user.is_active) { return res.status(401).json({ message: 'Account is disabled' }); } // Simple password check for dev (in production, use bcrypt.compare) if (user.password_hash !== password) { return res.status(401).json({ message: 'Invalid username or password' }); } // Generate session token const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours db.createSession(user.id, token, expiresAt); db.updateLastLogin(user.id); db.logAudit(user.id, 'login', 'user', user.id.toString(), null, req.ip); res.json({ token, user: { id: user.id, username: user.username, email: user.email, full_name: user.full_name, role: user.role }, expiresAt }); } catch (err) { console.error('Login error:', err); res.status(500).json({ message: 'Login failed', error: err.message }); } }); /** * Logout * POST /api/auth/logout */ app.post('/api/auth/logout', (req, res) => { try { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); const session = db.getSessionByToken(token); if (session) { db.logAudit(session.user_id, 'logout', 'user', session.user_id.toString(), null, req.ip); db.deleteSession(token); } } res.json({ message: 'Logged out successfully' }); } catch (err) { console.error('Logout error:', err); res.status(500).json({ message: 'Logout failed', error: err.message }); } }); /** * Get current user * GET /api/auth/me */ app.get('/api/auth/me', (req, res) => { try { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: 'No token provided' }); } const token = authHeader.slice(7); const session = db.getSessionByToken(token); if (!session) { return res.status(401).json({ message: 'Invalid or expired token' }); } if (!session.is_active) { return res.status(401).json({ message: 'Account is disabled' }); } res.json({ user: { id: session.user_id, username: session.username, full_name: session.full_name, role: session.role } }); } catch (err) { console.error('Get user error:', err); res.status(500).json({ message: 'Failed to get user', error: err.message }); } }); // ============================================ // AUTH MIDDLEWARE // ============================================ function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: 'Authentication required' }); } const token = authHeader.slice(7); const session = db.getSessionByToken(token); if (!session) { return res.status(401).json({ message: 'Invalid or expired token' }); } if (!session.is_active) { return res.status(401).json({ message: 'Account is disabled' }); } req.user = { id: session.user_id, username: session.username, role: session.role, fullName: session.full_name }; next(); } function adminMiddleware(req, res, next) { if (req.user.role !== 'admin') { return res.status(403).json({ message: 'Admin access required' }); } next(); } // ============================================ // ADMIN API - USER MANAGEMENT // ============================================ /** * List all users (admin only) * GET /api/admin/users */ app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { try { const users = db.listUsers(); res.json({ users }); } catch (err) { console.error('List users error:', err); res.status(500).json({ message: 'Failed to list users', error: err.message }); } }); /** * Get user by ID (admin only) * GET /api/admin/users/:userId */ app.get('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { try { const user = db.getUserById(parseInt(req.params.userId)); if (!user) { return res.status(404).json({ message: 'User not found' }); } res.json({ user }); } catch (err) { console.error('Get user error:', err); res.status(500).json({ message: 'Failed to get user', error: err.message }); } }); /** * Create user (admin only) * POST /api/admin/users */ app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { try { const { username, password, email, fullName, role = 'user' } = req.body; if (!username || !password) { return res.status(400).json({ message: 'Username and password are required' }); } // Check if username exists const existing = db.getUserByUsername(username); if (existing) { return res.status(400).json({ message: 'Username already exists' }); } // In production, hash the password with bcrypt const user = db.createUser(username, password, email, fullName, role); db.logAudit(req.user.id, 'create_user', 'user', user.id.toString(), { username, role }, req.ip); res.status(201).json({ user }); } catch (err) { console.error('Create user error:', err); res.status(500).json({ message: 'Failed to create user', error: err.message }); } }); /** * Update user (admin only) * PUT /api/admin/users/:userId */ app.put('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { try { const userId = parseInt(req.params.userId); const { email, fullName, role, isActive, password } = req.body; const user = db.getUserById(userId); if (!user) { return res.status(404).json({ message: 'User not found' }); } const updates = {}; if (email !== undefined) updates.email = email; if (fullName !== undefined) updates.fullName = fullName; if (role !== undefined) updates.role = role; if (isActive !== undefined) updates.isActive = isActive; if (password !== undefined) updates.passwordHash = password; // In production, hash with bcrypt db.updateUser(userId, updates); db.logAudit(req.user.id, 'update_user', 'user', userId.toString(), updates, req.ip); const updatedUser = db.getUserById(userId); res.json({ user: updatedUser }); } catch (err) { console.error('Update user error:', err); res.status(500).json({ message: 'Failed to update user', error: err.message }); } }); /** * Delete user (admin only) * DELETE /api/admin/users/:userId */ app.delete('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, res) => { try { const userId = parseInt(req.params.userId); // Prevent self-deletion if (userId === req.user.id) { return res.status(400).json({ message: 'Cannot delete your own account' }); } const user = db.getUserById(userId); if (!user) { return res.status(404).json({ message: 'User not found' }); } db.deleteUser(userId); db.logAudit(req.user.id, 'delete_user', 'user', userId.toString(), { username: user.username }, req.ip); res.json({ message: 'User deleted successfully' }); } catch (err) { console.error('Delete user error:', err); res.status(500).json({ message: 'Failed to delete user', error: err.message }); } }); // ============================================ // ADMIN API - CASES (WITH FILTERS) // ============================================ /** * List all cases with filters (admin only) * GET /api/admin/cases */ app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => { try { const { status, createdBy, search, limit = 50, offset = 0, sortBy, sortOrder, includeArchived, archivedOnly } = req.query; const result = db.listCasesFiltered({ status, createdBy: createdBy ? parseInt(createdBy) : undefined, search, limit: parseInt(limit), offset: parseInt(offset), sortBy, sortOrder, includeArchived: includeArchived === 'true', archivedOnly: archivedOnly === 'true' }); res.json(result); } catch (err) { console.error('List cases error:', err); res.status(500).json({ message: 'Failed to list cases', error: err.message }); } }); // ============================================ // ADMIN API - ANALYTICS // ============================================ /** * Get dashboard analytics (admin only) * GET /api/admin/analytics/dashboard */ app.get('/api/admin/analytics/dashboard', authMiddleware, adminMiddleware, (req, res) => { try { const caseStats = db.getCaseStats(); const userStats = db.getUserStats(); const rigoDistribution = db.getRigoDistribution(); const cobbStats = db.getCobbAngleStats(); const processingStats = db.getProcessingTimeStats(); const bodyScanStats = db.getBodyScanStats(); res.json({ cases: caseStats, users: userStats, rigoDistribution, cobbAngles: cobbStats, processingTime: processingStats, bodyScan: bodyScanStats }); } catch (err) { console.error('Get analytics error:', err); res.status(500).json({ message: 'Failed to get analytics', error: err.message }); } }); /** * Get Rigo distribution (admin only) * GET /api/admin/analytics/rigo */ app.get('/api/admin/analytics/rigo', authMiddleware, adminMiddleware, (req, res) => { try { const distribution = db.getRigoDistribution(); res.json({ distribution }); } catch (err) { console.error('Get Rigo distribution error:', err); res.status(500).json({ message: 'Failed to get Rigo distribution', error: err.message }); } }); /** * Get Cobb angle statistics (admin only) * GET /api/admin/analytics/cobb-angles */ app.get('/api/admin/analytics/cobb-angles', authMiddleware, adminMiddleware, (req, res) => { try { const stats = db.getCobbAngleStats(); res.json({ stats }); } catch (err) { console.error('Get Cobb angle stats error:', err); res.status(500).json({ message: 'Failed to get Cobb angle stats', error: err.message }); } }); /** * Get processing time statistics (admin only) * GET /api/admin/analytics/processing-time */ app.get('/api/admin/analytics/processing-time', authMiddleware, adminMiddleware, (req, res) => { try { const stats = db.getProcessingTimeStats(); res.json({ stats }); } catch (err) { console.error('Get processing time stats error:', err); res.status(500).json({ message: 'Failed to get processing time stats', error: err.message }); } }); // ============================================ // ADMIN API - AUDIT LOG // ============================================ /** * Get audit log (admin only) * GET /api/admin/audit-log */ app.get('/api/admin/audit-log', authMiddleware, adminMiddleware, (req, res) => { try { const { userId, action, entityType, limit = 100, offset = 0 } = req.query; const entries = db.getAuditLog({ userId: userId ? parseInt(userId) : undefined, action, entityType, limit: parseInt(limit), offset: parseInt(offset) }); res.json({ entries }); } catch (err) { console.error('Get audit log error:', err); res.status(500).json({ message: 'Failed to get audit log', error: err.message }); } }); // ============================================ // 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 // ============================================ app.listen(PORT, () => { console.log(''); console.log('============================================'); console.log('BraceFlow DEV API Server'); console.log('============================================'); console.log(`Port: ${PORT}`); console.log(`Brace Generator: ${BRACE_GENERATOR_URL}`); console.log(`Data Directory: ${DATA_DIR}`); console.log('============================================'); console.log(''); console.log('Endpoints:'); console.log(' GET /api/health'); console.log(' POST /api/cases Create case'); console.log(' GET /api/cases List cases'); console.log(' GET /api/cases/:id Get case'); console.log(' GET /api/cases/:id/status Get case status'); console.log(' POST /api/cases/:id/upload Upload X-ray'); console.log(' POST /api/cases/:id/detect-landmarks Stage 1: Detect'); console.log(' PUT /api/cases/:id/landmarks Update landmarks'); console.log(' POST /api/cases/:id/approve-landmarks Approve landmarks'); console.log(' POST /api/cases/:id/recalculate Recalculate analysis'); console.log(' POST /api/cases/:id/generate-brace Stage 3: Generate'); console.log(' GET /api/cases/:id/brace-outputs Get outputs'); console.log(' PUT /api/cases/:id/markers Update markers'); 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'); console.log(' GET /api/auth/me Get current user'); console.log(''); console.log('Admin Endpoints (require admin role):'); console.log(' GET /api/admin/users List users'); console.log(' POST /api/admin/users Create user'); console.log(' PUT /api/admin/users/:id Update user'); console.log(' DELETE /api/admin/users/:id Delete user'); 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(''); });