Files
braceiqmed/api/server.js

2066 lines
66 KiB
JavaScript

/**
* 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('');
});