2066 lines
66 KiB
JavaScript
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('');
|
|
});
|