/** * 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]); } } })); // 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 */ app.get('/api/cases', (req, res) => { try { const cases = db.listCases(); 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 // ============================================== /** * Delete case * DELETE /api/cases/:caseId */ app.delete('/api/cases/:caseId', (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 }); } res.json({ caseId, deleted: true }); } 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 }); } }); // ============================================ // 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 } = req.query; const result = db.listCasesFiltered({ status, createdBy: createdBy ? parseInt(createdBy) : undefined, search, limit: parseInt(limit), offset: parseInt(offset), sortBy, sortOrder }); 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 }); } }); // ============================================ // 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('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(''); });