Add patient management, deployment scripts, and Docker fixes

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

7
api/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.git
.gitignore
*.md
.env
.env.*

View File

@@ -12,10 +12,11 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies (rebuild native modules for Linux)
RUN npm ci --only=production RUN npm ci --only=production && \
npm rebuild better-sqlite3
# Copy application code # Copy application code (excluding node_modules via .dockerignore)
COPY . . COPY . .
# Create data directories # Create data directories

View File

@@ -18,10 +18,36 @@ db.pragma('journal_mode = WAL');
// Create tables // Create tables
db.exec(` db.exec(`
-- Main cases table -- Patients table
CREATE TABLE IF NOT EXISTS patients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mrn TEXT UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth TEXT,
gender TEXT CHECK(gender IN ('male', 'female', 'other')),
email TEXT,
phone TEXT,
address TEXT,
diagnosis TEXT,
curve_type TEXT,
medical_history TEXT,
referring_physician TEXT,
insurance_info TEXT,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
-- Main cases table (linked to patients)
CREATE TABLE IF NOT EXISTS brace_cases ( CREATE TABLE IF NOT EXISTS brace_cases (
case_id TEXT PRIMARY KEY, case_id TEXT PRIMARY KEY,
patient_id INTEGER,
case_type TEXT NOT NULL DEFAULT 'braceflow', case_type TEXT NOT NULL DEFAULT 'braceflow',
visit_date TEXT DEFAULT (date('now')),
status TEXT NOT NULL DEFAULT 'created' CHECK(status IN ( status TEXT NOT NULL DEFAULT 'created' CHECK(status IN (
'created', 'running', 'completed', 'failed', 'cancelled', 'created', 'running', 'completed', 'failed', 'cancelled',
'processing_brace', 'brace_generated', 'brace_failed', 'processing_brace', 'brace_generated', 'brace_failed',
@@ -38,9 +64,12 @@ db.exec(`
body_scan_path TEXT DEFAULT NULL, body_scan_path TEXT DEFAULT NULL,
body_scan_url TEXT DEFAULT NULL, body_scan_url TEXT DEFAULT NULL,
body_scan_metadata TEXT DEFAULT NULL, body_scan_metadata TEXT DEFAULT NULL,
is_archived INTEGER NOT NULL DEFAULT 0,
archived_at TEXT DEFAULT NULL,
created_by INTEGER DEFAULT NULL, created_by INTEGER DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (patient_id) REFERENCES patients(id) ON DELETE SET NULL
); );
-- Case steps table -- Case steps table
@@ -109,7 +138,35 @@ db.exec(`
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
); );
-- API request logging table (for tracking all HTTP API calls)
CREATE TABLE IF NOT EXISTS api_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT NULL,
username TEXT DEFAULT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
route_pattern TEXT DEFAULT NULL,
query_params TEXT DEFAULT NULL,
request_params TEXT DEFAULT NULL,
file_uploads TEXT DEFAULT NULL,
status_code INTEGER DEFAULT NULL,
response_time_ms INTEGER DEFAULT NULL,
response_summary TEXT DEFAULT NULL,
ip_address TEXT DEFAULT NULL,
user_agent TEXT DEFAULT NULL,
request_body_size INTEGER DEFAULT NULL,
response_body_size INTEGER DEFAULT NULL,
error_message TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Create indexes -- Create indexes
CREATE INDEX IF NOT EXISTS idx_patients_name ON patients(last_name, first_name);
CREATE INDEX IF NOT EXISTS idx_patients_mrn ON patients(mrn);
CREATE INDEX IF NOT EXISTS idx_patients_created_by ON patients(created_by);
CREATE INDEX IF NOT EXISTS idx_patients_active ON patients(is_active);
CREATE INDEX IF NOT EXISTS idx_cases_patient ON brace_cases(patient_id);
CREATE INDEX IF NOT EXISTS idx_cases_status ON brace_cases(status); CREATE INDEX IF NOT EXISTS idx_cases_status ON brace_cases(status);
CREATE INDEX IF NOT EXISTS idx_cases_created ON brace_cases(created_at); CREATE INDEX IF NOT EXISTS idx_cases_created ON brace_cases(created_at);
CREATE INDEX IF NOT EXISTS idx_steps_case_id ON brace_case_steps(case_id); CREATE INDEX IF NOT EXISTS idx_steps_case_id ON brace_case_steps(case_id);
@@ -120,6 +177,11 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_api_requests_user ON api_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_api_requests_path ON api_requests(path);
CREATE INDEX IF NOT EXISTS idx_api_requests_method ON api_requests(method);
CREATE INDEX IF NOT EXISTS idx_api_requests_status ON api_requests(status_code);
CREATE INDEX IF NOT EXISTS idx_api_requests_created ON api_requests(created_at);
`); `);
// Migration: Add new columns to existing tables // Migration: Add new columns to existing tables
@@ -143,6 +205,42 @@ try {
db.exec(`ALTER TABLE brace_cases ADD COLUMN created_by INTEGER DEFAULT NULL`); db.exec(`ALTER TABLE brace_cases ADD COLUMN created_by INTEGER DEFAULT NULL`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
// Migration: Add new columns to api_requests table for enhanced logging
try {
db.exec(`ALTER TABLE api_requests ADD COLUMN route_pattern TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
try {
db.exec(`ALTER TABLE api_requests ADD COLUMN request_params TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
try {
db.exec(`ALTER TABLE api_requests ADD COLUMN file_uploads TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
try {
db.exec(`ALTER TABLE api_requests ADD COLUMN response_summary TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
// Migration: Add patient_id to brace_cases
try {
db.exec(`ALTER TABLE brace_cases ADD COLUMN patient_id INTEGER DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
// Migration: Add visit_date to brace_cases
try {
db.exec(`ALTER TABLE brace_cases ADD COLUMN visit_date TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
// Migration: Add is_archived to brace_cases
try {
db.exec(`ALTER TABLE brace_cases ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* Column already exists */ }
try {
db.exec(`ALTER TABLE brace_cases ADD COLUMN archived_at TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
// Insert default admin user if not exists (password: admin123) // Insert default admin user if not exists (password: admin123)
// Note: In production, use proper bcrypt hashing. This is a simple hash for dev. // Note: In production, use proper bcrypt hashing. This is a simple hash for dev.
try { try {
@@ -168,12 +266,12 @@ const STEP_NAMES = [
]; ];
/** /**
* Create a new case * Create a new case (optionally linked to a patient)
*/ */
export function createCase(caseId, caseType = 'braceflow', notes = null) { export function createCase(caseId, caseType = 'braceflow', notes = null, patientId = null, visitDate = null) {
const insertCase = db.prepare(` const insertCase = db.prepare(`
INSERT INTO brace_cases (case_id, case_type, status, notes, created_at, updated_at) INSERT INTO brace_cases (case_id, patient_id, case_type, visit_date, status, notes, created_at, updated_at)
VALUES (?, ?, 'created', ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, 'created', ?, datetime('now'), datetime('now'))
`); `);
const insertStep = db.prepare(` const insertStep = db.prepare(`
@@ -182,40 +280,108 @@ export function createCase(caseId, caseType = 'braceflow', notes = null) {
`); `);
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
insertCase.run(caseId, caseType, notes); insertCase.run(caseId, patientId, caseType, visitDate || new Date().toISOString().split('T')[0], notes);
STEP_NAMES.forEach((stepName, idx) => { STEP_NAMES.forEach((stepName, idx) => {
insertStep.run(caseId, stepName, idx + 1); insertStep.run(caseId, stepName, idx + 1);
}); });
}); });
transaction(); transaction();
return { caseId, status: 'created', steps: STEP_NAMES }; return { caseId, patientId, status: 'created', steps: STEP_NAMES };
} }
/** /**
* List all cases * List all cases with patient info
* @param {Object} options - Query options
* @param {boolean} options.includeArchived - Include archived cases (for admin view)
* @param {boolean} options.archivedOnly - Only show archived cases
*/ */
export function listCases() { export function listCases(options = {}) {
const { includeArchived = false, archivedOnly = false } = options;
let whereClause = '';
if (archivedOnly) {
whereClause = 'WHERE c.is_archived = 1';
} else if (!includeArchived) {
whereClause = 'WHERE c.is_archived = 0';
}
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT case_id as caseId, case_type, status, current_step, notes, SELECT c.case_id as caseId, c.patient_id, c.case_type, c.visit_date, c.status, c.current_step, c.notes,
analysis_result, landmarks_data, created_at, updated_at c.analysis_result, c.landmarks_data, c.is_archived, c.archived_at, c.created_at, c.updated_at,
FROM brace_cases p.first_name as patient_first_name, p.last_name as patient_last_name,
ORDER BY created_at DESC p.mrn as patient_mrn
FROM brace_cases c
LEFT JOIN patients p ON c.patient_id = p.id
${whereClause}
ORDER BY c.created_at DESC
`); `);
return stmt.all();
const rows = stmt.all();
// Transform to include patient object
return rows.map(row => ({
caseId: row.caseId,
patient_id: row.patient_id,
patient: row.patient_id ? {
id: row.patient_id,
firstName: row.patient_first_name,
lastName: row.patient_last_name,
fullName: `${row.patient_first_name} ${row.patient_last_name}`,
mrn: row.patient_mrn
} : null,
case_type: row.case_type,
visit_date: row.visit_date,
status: row.status,
current_step: row.current_step,
notes: row.notes,
analysis_result: row.analysis_result,
landmarks_data: row.landmarks_data,
is_archived: row.is_archived === 1,
archived_at: row.archived_at,
created_at: row.created_at,
updated_at: row.updated_at
}));
} }
/** /**
* Get case by ID with steps * Archive a case (soft delete)
*/
export function archiveCase(caseId) {
const stmt = db.prepare(`
UPDATE brace_cases
SET is_archived = 1, archived_at = datetime('now'), updated_at = datetime('now')
WHERE case_id = ?
`);
return stmt.run(caseId);
}
/**
* Unarchive a case
*/
export function unarchiveCase(caseId) {
const stmt = db.prepare(`
UPDATE brace_cases
SET is_archived = 0, archived_at = NULL, updated_at = datetime('now')
WHERE case_id = ?
`);
return stmt.run(caseId);
}
/**
* Get case by ID with steps and patient info
*/ */
export function getCase(caseId) { export function getCase(caseId) {
const caseStmt = db.prepare(` const caseStmt = db.prepare(`
SELECT case_id, case_type, status, current_step, notes, SELECT c.case_id, c.patient_id, c.case_type, c.visit_date, c.status, c.current_step, c.notes,
analysis_result, landmarks_data, analysis_data, markers_data, c.analysis_result, c.landmarks_data, c.analysis_data, c.markers_data,
body_scan_path, body_scan_url, body_scan_metadata, c.body_scan_path, c.body_scan_url, c.body_scan_metadata,
created_at, updated_at c.created_at, c.updated_at,
FROM brace_cases p.first_name as patient_first_name, p.last_name as patient_last_name,
WHERE case_id = ? p.mrn as patient_mrn, p.date_of_birth as patient_dob, p.gender as patient_gender
FROM brace_cases c
LEFT JOIN patients p ON c.patient_id = p.id
WHERE c.case_id = ?
`); `);
const stepsStmt = db.prepare(` const stepsStmt = db.prepare(`
@@ -267,9 +433,23 @@ export function getCase(caseId) {
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// Build patient object if patient_id exists
const patient = caseData.patient_id ? {
id: caseData.patient_id,
firstName: caseData.patient_first_name,
lastName: caseData.patient_last_name,
fullName: `${caseData.patient_first_name} ${caseData.patient_last_name}`,
mrn: caseData.patient_mrn,
dateOfBirth: caseData.patient_dob,
gender: caseData.patient_gender
} : null;
return { return {
caseId: caseData.case_id, caseId: caseData.case_id,
patient_id: caseData.patient_id,
patient,
case_type: caseData.case_type, case_type: caseData.case_type,
visit_date: caseData.visit_date,
status: caseData.status, status: caseData.status,
current_step: caseData.current_step, current_step: caseData.current_step,
notes: caseData.notes, notes: caseData.notes,
@@ -458,6 +638,244 @@ export function updateStepStatus(caseId, stepName, status, errorMessage = null)
return stmt.run(status, errorMessage, status, status, caseId, stepName); return stmt.run(status, errorMessage, status, status, caseId, stepName);
} }
// ============================================
// PATIENT MANAGEMENT
// ============================================
/**
* Create a new patient
*/
export function createPatient(data) {
const stmt = db.prepare(`
INSERT INTO patients (
mrn, first_name, last_name, date_of_birth, gender,
email, phone, address, diagnosis, curve_type,
medical_history, referring_physician, insurance_info, notes,
created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`);
const result = stmt.run(
data.mrn || null,
data.firstName,
data.lastName,
data.dateOfBirth || null,
data.gender || null,
data.email || null,
data.phone || null,
data.address || null,
data.diagnosis || null,
data.curveType || null,
data.medicalHistory || null,
data.referringPhysician || null,
data.insuranceInfo || null,
data.notes || null,
data.createdBy || null
);
return {
id: result.lastInsertRowid,
...data
};
}
/**
* Get patient by ID
*/
export function getPatient(patientId) {
const stmt = db.prepare(`
SELECT p.*, u.username as created_by_username
FROM patients p
LEFT JOIN users u ON p.created_by = u.id
WHERE p.id = ?
`);
return stmt.get(patientId);
}
/**
* List all patients with optional filters
*/
export function listPatients(options = {}) {
const { search, isActive = true, createdBy, limit = 50, offset = 0, sortBy = 'created_at', sortOrder = 'DESC' } = options;
let where = [];
let values = [];
if (isActive !== undefined && isActive !== null) {
where.push('p.is_active = ?');
values.push(isActive ? 1 : 0);
}
if (createdBy) {
where.push('p.created_by = ?');
values.push(createdBy);
}
if (search) {
where.push('(p.first_name LIKE ? OR p.last_name LIKE ? OR p.mrn LIKE ? OR p.email LIKE ?)');
const searchPattern = `%${search}%`;
values.push(searchPattern, searchPattern, searchPattern, searchPattern);
}
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const validSortColumns = ['created_at', 'updated_at', 'last_name', 'first_name', 'date_of_birth'];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
// Get total count
const countStmt = db.prepare(`SELECT COUNT(*) as count FROM patients p ${whereClause}`);
const totalCount = countStmt.get(...values).count;
// Get patients with case count
const stmt = db.prepare(`
SELECT p.*,
u.username as created_by_username,
(SELECT COUNT(*) FROM brace_cases c WHERE c.patient_id = p.id) as case_count,
(SELECT MAX(c.created_at) FROM brace_cases c WHERE c.patient_id = p.id) as last_visit
FROM patients p
LEFT JOIN users u ON p.created_by = u.id
${whereClause}
ORDER BY p.${sortColumn} ${order}
LIMIT ? OFFSET ?
`);
const patients = stmt.all(...values, limit, offset);
return {
patients,
total: totalCount,
limit,
offset
};
}
/**
* Update patient
*/
export function updatePatient(patientId, data) {
const fields = [];
const values = [];
if (data.mrn !== undefined) { fields.push('mrn = ?'); values.push(data.mrn); }
if (data.firstName !== undefined) { fields.push('first_name = ?'); values.push(data.firstName); }
if (data.lastName !== undefined) { fields.push('last_name = ?'); values.push(data.lastName); }
if (data.dateOfBirth !== undefined) { fields.push('date_of_birth = ?'); values.push(data.dateOfBirth); }
if (data.gender !== undefined) { fields.push('gender = ?'); values.push(data.gender); }
if (data.email !== undefined) { fields.push('email = ?'); values.push(data.email); }
if (data.phone !== undefined) { fields.push('phone = ?'); values.push(data.phone); }
if (data.address !== undefined) { fields.push('address = ?'); values.push(data.address); }
if (data.diagnosis !== undefined) { fields.push('diagnosis = ?'); values.push(data.diagnosis); }
if (data.curveType !== undefined) { fields.push('curve_type = ?'); values.push(data.curveType); }
if (data.medicalHistory !== undefined) { fields.push('medical_history = ?'); values.push(data.medicalHistory); }
if (data.referringPhysician !== undefined) { fields.push('referring_physician = ?'); values.push(data.referringPhysician); }
if (data.insuranceInfo !== undefined) { fields.push('insurance_info = ?'); values.push(data.insuranceInfo); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes); }
if (data.isActive !== undefined) { fields.push('is_active = ?'); values.push(data.isActive ? 1 : 0); }
if (fields.length === 0) return null;
fields.push('updated_at = datetime(\'now\')');
values.push(patientId);
const stmt = db.prepare(`UPDATE patients SET ${fields.join(', ')} WHERE id = ?`);
return stmt.run(...values);
}
/**
* Archive patient (soft delete - set is_active = 0)
*/
export function archivePatient(patientId) {
const stmt = db.prepare(`
UPDATE patients
SET is_active = 0, updated_at = datetime('now')
WHERE id = ?
`);
return stmt.run(patientId);
}
/**
* Unarchive patient (restore - set is_active = 1)
*/
export function unarchivePatient(patientId) {
const stmt = db.prepare(`
UPDATE patients
SET is_active = 1, updated_at = datetime('now')
WHERE id = ?
`);
return stmt.run(patientId);
}
/**
* Delete patient - kept for backwards compatibility, now archives
*/
export function deletePatient(patientId, hard = false) {
if (hard) {
// Hard delete should never be used in normal operation
const stmt = db.prepare(`DELETE FROM patients WHERE id = ?`);
return stmt.run(patientId);
} else {
return archivePatient(patientId);
}
}
/**
* Get cases for a patient
* @param {number} patientId - Patient ID
* @param {Object} options - Query options
* @param {boolean} options.includeArchived - Include archived cases
*/
export function getPatientCases(patientId, options = {}) {
const { includeArchived = false } = options;
const archivedFilter = includeArchived ? '' : 'AND is_archived = 0';
const stmt = db.prepare(`
SELECT case_id, case_type, status, current_step, visit_date, notes,
analysis_result, landmarks_data, body_scan_path, body_scan_url,
is_archived, archived_at, created_at, updated_at
FROM brace_cases
WHERE patient_id = ? ${archivedFilter}
ORDER BY created_at DESC
`);
return stmt.all(patientId);
}
/**
* Get patient statistics
*/
export function getPatientStats() {
const total = db.prepare(`SELECT COUNT(*) as count FROM patients`).get();
const active = db.prepare(`SELECT COUNT(*) as count FROM patients WHERE is_active = 1`).get();
const withCases = db.prepare(`
SELECT COUNT(DISTINCT patient_id) as count
FROM brace_cases
WHERE patient_id IS NOT NULL
`).get();
const byGender = db.prepare(`
SELECT gender, COUNT(*) as count
FROM patients
WHERE is_active = 1
GROUP BY gender
`).all();
const recentPatients = db.prepare(`
SELECT COUNT(*) as count
FROM patients
WHERE created_at >= datetime('now', '-30 days')
`).get();
return {
total: total.count,
active: active.count,
inactive: total.count - active.count,
withCases: withCases.count,
byGender: byGender.reduce((acc, row) => { acc[row.gender || 'unspecified'] = row.count; return acc; }, {}),
recentPatients: recentPatients.count
};
}
// ============================================ // ============================================
// USER MANAGEMENT // USER MANAGEMENT
// ============================================ // ============================================
@@ -631,6 +1049,210 @@ export function getAuditLog(options = {}) {
return stmt.all(...values, limit, offset); return stmt.all(...values, limit, offset);
} }
// ============================================
// API REQUEST LOGGING
// ============================================
/**
* Log an API request with full details
*/
export function logApiRequest(data) {
const stmt = db.prepare(`
INSERT INTO api_requests (
user_id, username, method, path, route_pattern, query_params,
request_params, file_uploads, status_code, response_time_ms,
response_summary, ip_address, user_agent, request_body_size,
response_body_size, error_message, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`);
return stmt.run(
data.userId || null,
data.username || null,
data.method,
data.path,
data.routePattern || null,
data.queryParams ? JSON.stringify(data.queryParams) : null,
data.requestParams ? JSON.stringify(data.requestParams) : null,
data.fileUploads ? JSON.stringify(data.fileUploads) : null,
data.statusCode || null,
data.responseTimeMs || null,
data.responseSummary ? JSON.stringify(data.responseSummary) : null,
data.ipAddress || null,
data.userAgent || null,
data.requestBodySize || null,
data.responseBodySize || null,
data.errorMessage || null
);
}
/**
* Get API request logs with filters
*/
export function getApiRequests(options = {}) {
const {
userId,
username,
method,
path,
statusCode,
minStatusCode,
maxStatusCode,
startDate,
endDate,
limit = 100,
offset = 0
} = options;
let where = [];
let values = [];
if (userId) { where.push('user_id = ?'); values.push(userId); }
if (username) { where.push('username LIKE ?'); values.push(`%${username}%`); }
if (method) { where.push('method = ?'); values.push(method); }
if (path) { where.push('path LIKE ?'); values.push(`%${path}%`); }
if (statusCode) { where.push('status_code = ?'); values.push(statusCode); }
if (minStatusCode) { where.push('status_code >= ?'); values.push(minStatusCode); }
if (maxStatusCode) { where.push('status_code < ?'); values.push(maxStatusCode); }
if (startDate) { where.push('created_at >= ?'); values.push(startDate); }
if (endDate) { where.push('created_at <= ?'); values.push(endDate); }
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
// Get total count
const countStmt = db.prepare(`SELECT COUNT(*) as count FROM api_requests ${whereClause}`);
const totalCount = countStmt.get(...values).count;
// Get paginated results
const stmt = db.prepare(`
SELECT *
FROM api_requests
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const requests = stmt.all(...values, limit, offset);
return {
requests,
total: totalCount,
limit,
offset
};
}
/**
* Get API request statistics
*/
export function getApiRequestStats(options = {}) {
const { startDate, endDate } = options;
let where = [];
let values = [];
if (startDate) { where.push('created_at >= ?'); values.push(startDate); }
if (endDate) { where.push('created_at <= ?'); values.push(endDate); }
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
// Total requests
const total = db.prepare(`SELECT COUNT(*) as count FROM api_requests ${whereClause}`).get(...values);
// By method
const byMethod = db.prepare(`
SELECT method, COUNT(*) as count
FROM api_requests ${whereClause}
GROUP BY method ORDER BY count DESC
`).all(...values);
// By status code category
const byStatusCategory = db.prepare(`
SELECT
CASE
WHEN status_code >= 200 AND status_code < 300 THEN '2xx Success'
WHEN status_code >= 300 AND status_code < 400 THEN '3xx Redirect'
WHEN status_code >= 400 AND status_code < 500 THEN '4xx Client Error'
WHEN status_code >= 500 THEN '5xx Server Error'
ELSE 'Unknown'
END as category,
COUNT(*) as count
FROM api_requests ${whereClause}
GROUP BY category ORDER BY count DESC
`).all(...values);
// Top endpoints
const topEndpoints = db.prepare(`
SELECT method, path, COUNT(*) as count,
AVG(response_time_ms) as avg_response_time
FROM api_requests ${whereClause}
GROUP BY method, path
ORDER BY count DESC
LIMIT 20
`).all(...values);
// Top users
const topUsers = db.prepare(`
SELECT user_id, username, COUNT(*) as count
FROM api_requests
${whereClause ? whereClause + ' AND username IS NOT NULL' : 'WHERE username IS NOT NULL'}
GROUP BY user_id, username
ORDER BY count DESC
LIMIT 10
`).all(...values);
// Average response time
const avgResponseTime = db.prepare(`
SELECT AVG(response_time_ms) as avg,
MIN(response_time_ms) as min,
MAX(response_time_ms) as max
FROM api_requests
${whereClause ? whereClause + ' AND response_time_ms IS NOT NULL' : 'WHERE response_time_ms IS NOT NULL'}
`).get(...values);
// Requests per hour (last 24 hours)
const requestsPerHour = db.prepare(`
SELECT strftime('%Y-%m-%d %H:00', created_at) as hour, COUNT(*) as count
FROM api_requests
WHERE created_at >= datetime('now', '-24 hours')
GROUP BY hour
ORDER BY hour ASC
`).all();
// Error rate
const errors = db.prepare(`
SELECT COUNT(*) as count FROM api_requests
${whereClause ? whereClause + ' AND status_code >= 400' : 'WHERE status_code >= 400'}
`).get(...values);
return {
total: total.count,
byMethod: byMethod.reduce((acc, row) => { acc[row.method] = row.count; return acc; }, {}),
byStatusCategory: byStatusCategory.reduce((acc, row) => { acc[row.category] = row.count; return acc; }, {}),
topEndpoints,
topUsers,
responseTime: {
avg: Math.round(avgResponseTime?.avg || 0),
min: avgResponseTime?.min || 0,
max: avgResponseTime?.max || 0
},
requestsPerHour,
errorRate: total.count > 0 ? Math.round((errors.count / total.count) * 100 * 10) / 10 : 0
};
}
/**
* Cleanup old API request logs (older than N days)
*/
export function cleanupOldApiRequests(daysToKeep = 30) {
const stmt = db.prepare(`
DELETE FROM api_requests
WHERE created_at < datetime('now', '-' || ? || ' days')
`);
return stmt.run(daysToKeep);
}
// ============================================ // ============================================
// ANALYTICS QUERIES // ANALYTICS QUERIES
// ============================================ // ============================================
@@ -819,7 +1441,7 @@ export function getUserStats() {
* List cases with filters (for admin) * List cases with filters (for admin)
*/ */
export function listCasesFiltered(options = {}) { export function listCasesFiltered(options = {}) {
const { status, createdBy, search, limit = 50, offset = 0, sortBy = 'created_at', sortOrder = 'DESC' } = options; const { status, createdBy, search, limit = 50, offset = 0, sortBy = 'created_at', sortOrder = 'DESC', includeArchived = false, archivedOnly = false } = options;
let where = []; let where = [];
let values = []; let values = [];
@@ -828,6 +1450,13 @@ export function listCasesFiltered(options = {}) {
if (createdBy) { where.push('c.created_by = ?'); values.push(createdBy); } if (createdBy) { where.push('c.created_by = ?'); values.push(createdBy); }
if (search) { where.push('c.case_id LIKE ?'); values.push(`%${search}%`); } if (search) { where.push('c.case_id LIKE ?'); values.push(`%${search}%`); }
// Archive filtering
if (archivedOnly) {
where.push('c.is_archived = 1');
} else if (!includeArchived) {
where.push('c.is_archived = 0');
}
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const validSortColumns = ['created_at', 'updated_at', 'status', 'case_id']; const validSortColumns = ['created_at', 'updated_at', 'status', 'case_id'];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at'; const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
@@ -839,6 +1468,7 @@ export function listCasesFiltered(options = {}) {
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT c.case_id as caseId, c.case_type, c.status, c.current_step, c.notes, SELECT c.case_id as caseId, c.case_type, c.status, c.current_step, c.notes,
c.analysis_result, c.landmarks_data, c.body_scan_path, c.analysis_result, c.landmarks_data, c.body_scan_path,
c.is_archived, c.archived_at,
c.created_by, c.created_at, c.updated_at, c.created_by, c.created_at, c.updated_at,
u.username as created_by_username u.username as created_by_username
FROM brace_cases c FROM brace_cases c
@@ -848,7 +1478,10 @@ export function listCasesFiltered(options = {}) {
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`); `);
const cases = stmt.all(...values, limit, offset); const cases = stmt.all(...values, limit, offset).map(row => ({
...row,
is_archived: row.is_archived === 1
}));
return { return {
cases, cases,
@@ -859,6 +1492,7 @@ export function listCasesFiltered(options = {}) {
} }
export default { export default {
// Case management
createCase, createCase,
listCases, listCases,
listCasesFiltered, listCasesFiltered,
@@ -874,8 +1508,20 @@ export default {
saveBodyScan, saveBodyScan,
clearBodyScan, clearBodyScan,
deleteCase, deleteCase,
archiveCase,
unarchiveCase,
updateStepStatus, updateStepStatus,
STEP_NAMES, STEP_NAMES,
// Patient management
createPatient,
getPatient,
listPatients,
updatePatient,
deletePatient,
archivePatient,
unarchivePatient,
getPatientCases,
getPatientStats,
// User management // User management
getUserByUsername, getUserByUsername,
getUserById, getUserById,
@@ -892,6 +1538,11 @@ export default {
// Audit logging // Audit logging
logAudit, logAudit,
getAuditLog, getAuditLog,
// API request logging
logApiRequest,
getApiRequests,
getApiRequestStats,
cleanupOldApiRequests,
// Analytics // Analytics
getCaseStats, getCaseStats,
getRigoDistribution, getRigoDistribution,

View File

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

View File

@@ -27,7 +27,7 @@ WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip && \ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
# Copy and install requirements (from brace-generator folder) # Copy and install requirements
COPY brace-generator/requirements.txt /app/requirements.txt COPY brace-generator/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@@ -35,8 +35,16 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY scoliovis-api/requirements.txt /app/requirements-scoliovis.txt COPY scoliovis-api/requirements.txt /app/requirements-scoliovis.txt
RUN pip install --no-cache-dir -r requirements-scoliovis.txt || true RUN pip install --no-cache-dir -r requirements-scoliovis.txt || true
# Copy brace-generator code # Create brace_generator package structure
COPY brace-generator/ /app/brace_generator/server_DEV/ RUN mkdir -p /app/brace_generator
# Copy brace-generator code as a package
COPY brace-generator/*.py /app/brace_generator/
COPY brace-generator/__init__.py /app/brace_generator/__init__.py
# Also keep server_DEV structure for compatibility
RUN mkdir -p /app/brace_generator/server_DEV
COPY brace-generator/*.py /app/brace_generator/server_DEV/
# Copy scoliovis-api # Copy scoliovis-api
COPY scoliovis-api/ /app/scoliovis-api/ COPY scoliovis-api/ /app/scoliovis-api/
@@ -44,8 +52,8 @@ COPY scoliovis-api/ /app/scoliovis-api/
# Copy templates # Copy templates
COPY templates/ /app/templates/ COPY templates/ /app/templates/
# Set Python path # Set Python path - include both locations
ENV PYTHONPATH=/app:/app/brace_generator/server_DEV:/app/scoliovis-api ENV PYTHONPATH=/app:/app/scoliovis-api
# Environment variables # Environment variables
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
@@ -61,8 +69,8 @@ RUN mkdir -p /tmp/brace_generator /app/data/uploads /app/data/outputs
EXPOSE 8002 EXPOSE 8002
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1 CMD curl -f http://localhost:8002/health || exit 1
# Run the server # Run the server from the brace_generator package
CMD ["python", "-m", "uvicorn", "brace_generator.server_DEV.app:app", "--host", "0.0.0.0", "--port", "8002"] CMD ["python", "-m", "uvicorn", "brace_generator.server_DEV.app:app", "--host", "0.0.0.0", "--port", "8002"]

508
brace-generator/adapters.py Normal file
View File

@@ -0,0 +1,508 @@
"""
Model adapters that convert different model outputs to unified Spine2D format.
Each adapter wraps a specific model and produces consistent output.
"""
import sys
import numpy as np
from pathlib import Path
from typing import Optional, Dict, Any
from abc import ABC, abstractmethod
from data_models import VertebraLandmark, Spine2D
class BaseLandmarkAdapter(ABC):
"""Base class for landmark detection model adapters."""
@abstractmethod
def predict(self, image: np.ndarray) -> Spine2D:
"""
Run inference on an image and return unified spine landmarks.
Args:
image: Input image as numpy array (grayscale or RGB)
Returns:
Spine2D object with detected landmarks
"""
pass
@property
@abstractmethod
def name(self) -> str:
"""Model name for identification."""
pass
class ScolioVisAdapter(BaseLandmarkAdapter):
"""
Adapter for ScolioVis-API (Keypoint R-CNN model).
Uses the original ScolioVis inference code for best accuracy.
Outputs: 4 keypoints per vertebra + Cobb angles (PT, MT, TL) + curve type (S/C)
"""
def __init__(self, weights_path: Optional[str] = None, device: str = 'cpu'):
"""
Initialize ScolioVis model.
Args:
weights_path: Path to keypointsrcnn_weights.pt (auto-detects if None)
device: 'cpu' or 'cuda'
"""
self.device = device
self.model = None
self.weights_path = weights_path
self._scoliovis_path = None
self._load_model()
def _load_model(self):
"""Load the Keypoint R-CNN model."""
import torch
import torchvision
from torchvision.models.detection import keypointrcnn_resnet50_fpn
from torchvision.models.detection.rpn import AnchorGenerator
# Find weights and scoliovis module
scoliovis_api_path = Path(__file__).parent.parent / 'scoliovis-api'
if self.weights_path is None:
possible_paths = [
scoliovis_api_path / 'models' / 'keypointsrcnn_weights.pt',
scoliovis_api_path / 'keypointsrcnn_weights.pt',
scoliovis_api_path / 'weights' / 'keypointsrcnn_weights.pt',
]
for p in possible_paths:
if p.exists():
self.weights_path = str(p)
break
if self.weights_path is None or not Path(self.weights_path).exists():
raise FileNotFoundError(
"ScolioVis weights not found. Please provide weights_path or ensure "
"scoliovis-api/models/keypointsrcnn_weights.pt exists."
)
# Store path to scoliovis module for Cobb angle calculation
self._scoliovis_path = scoliovis_api_path
# Create model with same anchor generator as original training
anchor_generator = AnchorGenerator(
sizes=(32, 64, 128, 256, 512),
aspect_ratios=(0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0)
)
self.model = keypointrcnn_resnet50_fpn(
weights=None,
weights_backbone=None,
num_classes=2, # background + vertebra
num_keypoints=4, # 4 corners per vertebra
rpn_anchor_generator=anchor_generator
)
# Load weights
checkpoint = torch.load(self.weights_path, map_location=self.device, weights_only=False)
self.model.load_state_dict(checkpoint)
self.model.to(self.device)
self.model.eval()
print(f"ScolioVis model loaded from {self.weights_path}")
@property
def name(self) -> str:
return "ScolioVis-API"
def _filter_output(self, output, max_verts: int = 17):
"""
Filter model output using NMS and score threshold.
Matches the original ScolioVis filtering logic.
"""
import torch
import torchvision
scores = output['scores'].detach().cpu().numpy()
# Get indices of scores over threshold (0.5)
high_scores_idxs = np.where(scores > 0.5)[0].tolist()
if len(high_scores_idxs) == 0:
return [], [], []
# Apply NMS with IoU threshold 0.3
post_nms_idxs = torchvision.ops.nms(
output['boxes'][high_scores_idxs],
output['scores'][high_scores_idxs],
0.3
).cpu().numpy()
# Get filtered results
np_keypoints = output['keypoints'][high_scores_idxs][post_nms_idxs].detach().cpu().numpy()
np_bboxes = output['boxes'][high_scores_idxs][post_nms_idxs].detach().cpu().numpy()
np_scores = output['scores'][high_scores_idxs][post_nms_idxs].detach().cpu().numpy()
# Take top N by score (usually 17 for full spine)
sorted_scores_idxs = np.argsort(-1 * np_scores)
np_scores = np_scores[sorted_scores_idxs][:max_verts]
np_keypoints = np.array([np_keypoints[idx] for idx in sorted_scores_idxs])[:max_verts]
np_bboxes = np.array([np_bboxes[idx] for idx in sorted_scores_idxs])[:max_verts]
# Sort by ymin (top to bottom)
if len(np_keypoints) > 0:
ymins = np.array([kps[0][1] for kps in np_keypoints])
sorted_ymin_idxs = np.argsort(ymins)
np_scores = np.array([np_scores[idx] for idx in sorted_ymin_idxs])
np_keypoints = np.array([np_keypoints[idx] for idx in sorted_ymin_idxs])
np_bboxes = np.array([np_bboxes[idx] for idx in sorted_ymin_idxs])
# Convert to lists
keypoints_list = []
for kps in np_keypoints:
keypoints_list.append([list(map(float, kp[:2])) for kp in kps])
bboxes_list = []
for bbox in np_bboxes:
bboxes_list.append(list(map(int, bbox.tolist())))
scores_list = np_scores.tolist()
return bboxes_list, keypoints_list, scores_list
def predict(self, image: np.ndarray) -> Spine2D:
"""Run inference and return unified landmarks with ScolioVis Cobb angles."""
import torch
from torchvision.transforms import functional as F
# Ensure RGB
if len(image.shape) == 2:
image_rgb = np.stack([image, image, image], axis=-1)
else:
image_rgb = image
image_shape = image_rgb.shape # (H, W, C)
# Convert to tensor (ScolioVis uses torchvision's to_tensor)
img_tensor = F.to_tensor(image_rgb).to(self.device)
# Run inference
with torch.no_grad():
outputs = self.model([img_tensor])
# Filter output using original ScolioVis logic
bboxes, keypoints, scores = self._filter_output(outputs[0])
if len(keypoints) == 0:
return Spine2D(
vertebrae=[],
image_shape=image_shape[:2],
source_model=self.name
)
# Convert to unified format
vertebrae = []
for i in range(len(bboxes)):
kps = np.array(keypoints[i], dtype=np.float32) # (4, 2)
# Corners order from ScolioVis: [top_left, top_right, bottom_left, bottom_right]
corners = kps
centroid = np.mean(corners, axis=0)
# Compute orientation from top edge (kps[0] to kps[1])
top_left, top_right = corners[0], corners[1]
dx = top_right[0] - top_left[0]
dy = top_right[1] - top_left[1]
orientation = np.degrees(np.arctan2(dy, dx))
vert = VertebraLandmark(
level=None, # ScolioVis doesn't assign levels
centroid_px=centroid,
corners_px=corners,
endplate_upper_px=corners[:2], # top-left, top-right
endplate_lower_px=corners[2:], # bottom-left, bottom-right
orientation_deg=orientation,
confidence=float(scores[i]),
meta={'box': bboxes[i]}
)
vertebrae.append(vert)
# Create Spine2D
spine = Spine2D(
vertebrae=vertebrae,
image_shape=image_shape[:2],
source_model=self.name
)
# Use original ScolioVis Cobb angle calculation if available
if len(keypoints) >= 5:
try:
# Import original ScolioVis cobb_angle_cal
if str(self._scoliovis_path) not in sys.path:
sys.path.insert(0, str(self._scoliovis_path))
from scoliovis.cobb_angle_cal import cobb_angle_cal, keypoints_to_landmark_xy
landmark_xy = keypoints_to_landmark_xy(keypoints)
cobb_angles_list, angles_with_pos, curve_type, midpoint_lines = cobb_angle_cal(
landmark_xy, image_shape
)
# Store Cobb angles in spine object
spine.cobb_angles = {
'PT': cobb_angles_list[0],
'MT': cobb_angles_list[1],
'TL': cobb_angles_list[2]
}
spine.curve_type = curve_type
spine.meta = {
'angles_with_pos': angles_with_pos,
'midpoint_lines': midpoint_lines
}
except Exception as e:
print(f"Warning: Could not use ScolioVis Cobb calculation: {e}")
# Fallback to our own calculation
from spine_analysis import compute_cobb_angles
compute_cobb_angles(spine)
return spine
class VertLandmarkAdapter(BaseLandmarkAdapter):
"""
Adapter for Vertebra-Landmark-Detection (SpineNet model).
Outputs: 68 landmarks (4 corners × 17 vertebrae)
"""
def __init__(self, weights_path: Optional[str] = None, device: str = 'cpu'):
"""
Initialize SpineNet model.
Args:
weights_path: Path to model_last.pth (auto-detects if None)
device: 'cpu' or 'cuda'
"""
self.device = device
self.model = None
self.weights_path = weights_path
self._load_model()
def _load_model(self):
"""Load the SpineNet model."""
import torch
# Find weights
if self.weights_path is None:
possible_paths = [
Path(__file__).parent.parent / 'Vertebra-Landmark-Detection' / 'weights_spinal' / 'model_last.pth',
]
for p in possible_paths:
if p.exists():
self.weights_path = str(p)
break
if self.weights_path is None or not Path(self.weights_path).exists():
raise FileNotFoundError(
"Vertebra-Landmark-Detection weights not found. "
"Download from Google Drive and place in weights_spinal/model_last.pth"
)
# Add repo to path to import model
repo_path = Path(__file__).parent.parent / 'Vertebra-Landmark-Detection'
if str(repo_path) not in sys.path:
sys.path.insert(0, str(repo_path))
from models import spinal_net
# Create model
heads = {'hm': 1, 'reg': 2, 'wh': 8}
self.model = spinal_net.SpineNet(
heads=heads,
pretrained=False,
down_ratio=4,
final_kernel=1,
head_conv=256
)
# Load weights
checkpoint = torch.load(self.weights_path, map_location=self.device, weights_only=False)
self.model.load_state_dict(checkpoint['state_dict'], strict=False)
self.model.to(self.device)
self.model.eval()
print(f"Vertebra-Landmark-Detection model loaded from {self.weights_path}")
@property
def name(self) -> str:
return "Vertebra-Landmark-Detection"
def _nms(self, heat, kernel=3):
"""Apply NMS using max pooling."""
import torch
import torch.nn.functional as F
hmax = F.max_pool2d(heat, (kernel, kernel), stride=1, padding=(kernel - 1) // 2)
keep = (hmax == heat).float()
return heat * keep
def _gather_feat(self, feat, ind):
"""Gather features by index."""
dim = feat.size(2)
ind = ind.unsqueeze(2).expand(ind.size(0), ind.size(1), dim)
feat = feat.gather(1, ind)
return feat
def _tranpose_and_gather_feat(self, feat, ind):
"""Transpose and gather features - matches original decoder."""
feat = feat.permute(0, 2, 3, 1).contiguous()
feat = feat.view(feat.size(0), -1, feat.size(3))
feat = self._gather_feat(feat, ind)
return feat
def _decode_predictions(self, output: Dict, down_ratio: int = 4, K: int = 17):
"""Decode model output using original decoder logic."""
import torch
hm = output['hm'].sigmoid()
reg = output['reg']
wh = output['wh']
batch, cat, height, width = hm.size()
# Apply NMS
hm = self._nms(hm)
# Get top K from heatmap
topk_scores, topk_inds = torch.topk(hm.view(batch, cat, -1), K)
topk_inds = topk_inds % (height * width)
topk_ys = (topk_inds // width).float()
topk_xs = (topk_inds % width).float()
# Get overall top K
topk_score, topk_ind = torch.topk(topk_scores.view(batch, -1), K)
topk_inds = self._gather_feat(topk_inds.view(batch, -1, 1), topk_ind).view(batch, K)
topk_ys = self._gather_feat(topk_ys.view(batch, -1, 1), topk_ind).view(batch, K)
topk_xs = self._gather_feat(topk_xs.view(batch, -1, 1), topk_ind).view(batch, K)
scores = topk_score.view(batch, K, 1)
# Get regression offset and apply
reg = self._tranpose_and_gather_feat(reg, topk_inds)
reg = reg.view(batch, K, 2)
xs = topk_xs.view(batch, K, 1) + reg[:, :, 0:1]
ys = topk_ys.view(batch, K, 1) + reg[:, :, 1:2]
# Get corner offsets
wh = self._tranpose_and_gather_feat(wh, topk_inds)
wh = wh.view(batch, K, 8)
# Calculate corners by SUBTRACTING offsets (original decoder logic)
tl_x = xs - wh[:, :, 0:1]
tl_y = ys - wh[:, :, 1:2]
tr_x = xs - wh[:, :, 2:3]
tr_y = ys - wh[:, :, 3:4]
bl_x = xs - wh[:, :, 4:5]
bl_y = ys - wh[:, :, 5:6]
br_x = xs - wh[:, :, 6:7]
br_y = ys - wh[:, :, 7:8]
# Combine into output format: [cx, cy, tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y, score]
pts = torch.cat([xs, ys, tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y, scores], dim=2)
# Scale to image coordinates
pts[:, :, :10] *= down_ratio
return pts[0].cpu().numpy() # (K, 11)
def predict(self, image: np.ndarray) -> Spine2D:
"""Run inference and return unified landmarks."""
import torch
import cv2
# Ensure RGB
if len(image.shape) == 2:
image_rgb = np.stack([image, image, image], axis=-1)
else:
image_rgb = image
orig_h, orig_w = image_rgb.shape[:2]
# Resize to model input size (1024x512)
input_h, input_w = 1024, 512
img_resized = cv2.resize(image_rgb, (input_w, input_h))
# Normalize and convert to tensor - use original preprocessing!
# Original: out_image = image / 255. - 0.5 (NOT ImageNet stats)
img_tensor = torch.from_numpy(img_resized).permute(2, 0, 1).float() / 255.0 - 0.5
img_tensor = img_tensor.unsqueeze(0).to(self.device)
# Run inference
with torch.no_grad():
output = self.model(img_tensor)
# Decode predictions - returns (K, 11) array
# Format: [cx, cy, tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y, score]
pts = self._decode_predictions(output, down_ratio=4, K=17)
# Scale coordinates back to original image size
scale_x = orig_w / input_w
scale_y = orig_h / input_h
# Convert to unified format
vertebrae = []
threshold = 0.3
for i in range(len(pts)):
score = pts[i, 10]
if score < threshold:
continue
# Get center and corners (already scaled by down_ratio in decoder)
cx = pts[i, 0] * scale_x
cy = pts[i, 1] * scale_y
# Corners: tl, tr, bl, br
tl = np.array([pts[i, 2] * scale_x, pts[i, 3] * scale_y])
tr = np.array([pts[i, 4] * scale_x, pts[i, 5] * scale_y])
bl = np.array([pts[i, 6] * scale_x, pts[i, 7] * scale_y])
br = np.array([pts[i, 8] * scale_x, pts[i, 9] * scale_y])
# Reorder to [tl, tr, br, bl] for consistency with ScolioVis
corners = np.array([tl, tr, br, bl], dtype=np.float32)
centroid = np.array([cx, cy], dtype=np.float32)
# Compute orientation from top edge
dx = tr[0] - tl[0]
dy = tr[1] - tl[1]
orientation = np.degrees(np.arctan2(dy, dx))
vert = VertebraLandmark(
level=None,
centroid_px=centroid,
corners_px=corners,
endplate_upper_px=np.array([tl, tr]), # top edge
endplate_lower_px=np.array([bl, br]), # bottom edge
orientation_deg=orientation,
confidence=float(score),
meta={'raw_pts': pts[i].tolist()}
)
vertebrae.append(vert)
# Sort by y-coordinate (top to bottom)
vertebrae.sort(key=lambda v: float(v.centroid_px[1]))
# Assign vertebra levels (T1-L5 = 17 vertebrae typically)
level_names = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12', 'L1', 'L2', 'L3', 'L4', 'L5']
for i, vert in enumerate(vertebrae):
if i < len(level_names):
vert.level = level_names[i]
# Create Spine2D
spine = Spine2D(
vertebrae=vertebrae,
image_shape=(orig_h, orig_w),
source_model=self.name
)
# Compute Cobb angles
if len(vertebrae) >= 7:
from spine_analysis import compute_cobb_angles
compute_cobb_angles(spine)
return spine

View File

@@ -0,0 +1,354 @@
"""
Brace surface generation from spine landmarks.
Two modes:
- Version A: Generic/average body shape (parametric torso)
- Version B: Uses actual 3D body scan mesh
"""
import numpy as np
from typing import Tuple, Optional, List
from pathlib import Path
from .data_models import Spine2D, BraceConfig
from .spine_analysis import compute_spine_curve, find_apex_vertebrae
try:
import trimesh
HAS_TRIMESH = True
except ImportError:
HAS_TRIMESH = False
class BraceGenerator:
"""
Generates 3D brace shell from spine landmarks.
"""
def __init__(self, config: Optional[BraceConfig] = None):
"""
Initialize brace generator.
Args:
config: Brace configuration parameters
"""
if not HAS_TRIMESH:
raise ImportError("trimesh is required for brace generation. Install with: pip install trimesh")
self.config = config or BraceConfig()
def generate(self, spine: Spine2D) -> 'trimesh.Trimesh':
"""
Generate brace mesh from spine landmarks.
Args:
spine: Spine2D object with detected vertebrae
Returns:
trimesh.Trimesh object representing the brace shell
"""
if self.config.use_body_scan and self.config.body_scan_path:
return self._generate_from_body_scan(spine)
else:
return self._generate_from_average_body(spine)
def _torso_profile(self, z01: float) -> Tuple[float, float]:
"""
Get torso cross-section radii at a given height.
Args:
z01: Normalized height (0=top, 1=bottom)
Returns:
(a_mm, b_mm): Radii in left-right and front-back directions
"""
# Torso shape varies with height
# Wider at chest (z~0.3) and hips (z~0.8), narrower at waist (z~0.5)
# Base radii from config
base_a = self.config.torso_width_mm / 2
base_b = self.config.torso_depth_mm / 2
# Shape modulation
# Chest region (z ~ 0.2-0.4): wider
# Waist region (z ~ 0.5): narrower
# Hip region (z ~ 0.8-1.0): wider
if z01 < 0.3:
# Upper chest - moderate width
mod = 1.0
elif z01 < 0.5:
# Transition to waist
t = (z01 - 0.3) / 0.2
mod = 1.0 - 0.15 * t # Decrease by 15%
elif z01 < 0.7:
# Waist region - narrowest
mod = 0.85
else:
# Hips - widen again
t = (z01 - 0.7) / 0.3
mod = 0.85 + 0.2 * t # Increase by 20%
return base_a * mod, base_b * mod
def _generate_from_average_body(self, spine: Spine2D) -> 'trimesh.Trimesh':
"""
Generate brace using parametric average body shape.
The brace follows the spine curve laterally and applies
pressure zones at curve apexes.
"""
cfg = self.config
# 1) Compute spine curve
try:
C_px, T_px, N_px, curvature = compute_spine_curve(spine, smooth=5.0, n_samples=cfg.n_vertical_slices)
except ValueError as e:
raise ValueError(f"Cannot generate brace: {e}")
# 2) Convert to mm
if spine.pixel_spacing_mm is not None:
sx, sy = spine.pixel_spacing_mm
elif cfg.pixel_spacing_mm is not None:
sx, sy = cfg.pixel_spacing_mm
else:
sx = sy = 0.25 # Default assumption
C_mm = np.zeros_like(C_px)
C_mm[:, 0] = C_px[:, 0] * sx
C_mm[:, 1] = C_px[:, 1] * sy
# 3) Determine brace vertical extent
y_mm = C_mm[:, 1]
y_min, y_max = y_mm.min(), y_mm.max()
spine_height = y_max - y_min
# Brace height (might extend beyond detected vertebrae)
brace_height = min(cfg.brace_height_mm, spine_height * 1.1)
# 4) Normalize curvature for pressure zones
curv_norm = (curvature - curvature.min()) / (curvature.max() - curvature.min() + 1e-8)
# 5) Build vertices
n_z = cfg.n_vertical_slices
n_theta = cfg.n_circumference_points
# Opening angle (front of brace might be open)
opening_half = np.radians(cfg.front_opening_deg / 2)
vertices = []
for i in range(n_z):
z01 = i / (n_z - 1) # 0 to 1
# Z coordinate (vertical position in 3D)
z_mm = y_min + z01 * spine_height
# Get torso profile at this height
a_mm, b_mm = self._torso_profile(z01)
# Lateral offset from spine curve
x_offset = C_mm[i, 0] - (C_mm[0, 0] + C_mm[-1, 0]) / 2 # Deviation from midline
# Pressure modulation based on curvature
pressure = cfg.pressure_strength_mm * curv_norm[i]
for j in range(n_theta):
theta = 2 * np.pi * (j / n_theta)
# Skip vertices in the opening region (front = theta around 0)
# Actually, we'll still create them but can mark them for later removal
# Base ellipse point
x = a_mm * np.cos(theta)
y = b_mm * np.sin(theta)
# Apply lateral offset (brace follows spine curve)
x += x_offset
# Apply pressure zones
# Pressure on sides (theta near π/2 or 3π/2 = sides)
# The side that's convex gets pushed in
side_factor = abs(np.cos(theta)) # Max at sides (theta=0 or π)
# Determine which side based on spine deviation
if x_offset > 0:
# Spine deviated right, push on right side
if np.cos(theta) > 0: # Right side
x -= pressure * side_factor
else:
# Spine deviated left, push on left side
if np.cos(theta) < 0: # Left side
x -= pressure * side_factor * np.sign(np.cos(theta))
# Vertex position: x=left/right, y=front/back, z=vertical
vertices.append([x, y, z_mm])
vertices = np.array(vertices, dtype=np.float32)
# 6) Build faces (quad strips between adjacent rings)
faces = []
def vid(i, j):
return i * n_theta + (j % n_theta)
for i in range(n_z - 1):
for j in range(n_theta):
j2 = (j + 1) % n_theta
# Two triangles per quad
a = vid(i, j)
b = vid(i, j2)
c = vid(i + 1, j2)
d = vid(i + 1, j)
faces.append([a, b, c])
faces.append([a, c, d])
faces = np.array(faces, dtype=np.int32)
# 7) Create outer shell mesh
outer_shell = trimesh.Trimesh(vertices=vertices, faces=faces, process=True)
# 8) Create inner shell (offset inward by wall thickness)
outer_shell.fix_normals()
vn = outer_shell.vertex_normals
inner_vertices = vertices - cfg.wall_thickness_mm * vn
# Inner faces need reversed winding
inner_faces = faces[:, ::-1]
# 9) Combine into solid shell
all_vertices = np.vstack([vertices, inner_vertices])
inner_faces_offset = inner_faces + len(vertices)
all_faces = np.vstack([faces, inner_faces_offset])
# 10) Add end caps (top and bottom rings)
# Top cap (connect outer to inner at z=0)
top_faces = []
for j in range(n_theta):
j2 = (j + 1) % n_theta
outer_j = vid(0, j)
outer_j2 = vid(0, j2)
inner_j = outer_j + len(vertices)
inner_j2 = outer_j2 + len(vertices)
top_faces.append([outer_j, inner_j, inner_j2])
top_faces.append([outer_j, inner_j2, outer_j2])
# Bottom cap
bottom_faces = []
for j in range(n_theta):
j2 = (j + 1) % n_theta
outer_j = vid(n_z - 1, j)
outer_j2 = vid(n_z - 1, j2)
inner_j = outer_j + len(vertices)
inner_j2 = outer_j2 + len(vertices)
bottom_faces.append([outer_j, outer_j2, inner_j2])
bottom_faces.append([outer_j, inner_j2, inner_j])
all_faces = np.vstack([all_faces, top_faces, bottom_faces])
# Create final mesh
brace = trimesh.Trimesh(vertices=all_vertices, faces=all_faces, process=True)
brace.merge_vertices()
# Remove degenerate faces
valid_faces = brace.nondegenerate_faces()
brace.update_faces(valid_faces)
brace.fix_normals()
return brace
def _generate_from_body_scan(self, spine: Spine2D) -> 'trimesh.Trimesh':
"""
Generate brace by offsetting from a 3D body scan mesh.
The body scan provides the actual torso shape, and we:
1. Offset outward for clearance
2. Apply pressure zones based on spine curvature
3. Thicken for wall thickness
"""
cfg = self.config
if not cfg.body_scan_path or not Path(cfg.body_scan_path).exists():
raise FileNotFoundError(f"Body scan not found: {cfg.body_scan_path}")
# Load body scan
body = trimesh.load(cfg.body_scan_path, force='mesh')
body.remove_unreferenced_vertices()
body.fix_normals()
# Compute spine curve for pressure mapping
try:
C_px, T_px, N_px, curvature = compute_spine_curve(spine, smooth=5.0, n_samples=200)
except ValueError:
curvature = np.zeros(200)
# Convert spine coordinates to mm
if spine.pixel_spacing_mm is not None:
sx, sy = spine.pixel_spacing_mm
else:
sx = sy = 0.25
y_mm = C_px[:, 1] * sy
y_min, y_max = y_mm.min(), y_mm.max()
H = y_max - y_min + 1e-6
# Normalize curvature
curv_norm = (curvature - curvature.min()) / (curvature.max() - curvature.min() + 1e-8)
# 1) Offset body surface outward for clearance (inner brace surface)
clearance_mm = 6.0 # Gap between body and brace
vn = body.vertex_normals
inner_surface = trimesh.Trimesh(
vertices=body.vertices + clearance_mm * vn,
faces=body.faces.copy(),
process=True
)
# 2) Apply pressure deformation
# Map each vertex's Z coordinate to spine curvature
z_coords = inner_surface.vertices[:, 2] # Assuming Z is vertical
z_min, z_max = z_coords.min(), z_coords.max()
z01 = (z_coords - z_min) / (z_max - z_min + 1e-6)
# Sample curvature at each vertex height
curv_idx = np.clip((z01 * (len(curv_norm) - 1)).astype(int), 0, len(curv_norm) - 1)
pressure_per_vertex = cfg.pressure_strength_mm * curv_norm[curv_idx]
# Apply pressure on sides (based on X coordinate)
x_coords = inner_surface.vertices[:, 0]
x_range = np.abs(x_coords).max() + 1e-6
side_factor = np.abs(x_coords) / x_range # 0 at center, 1 at sides
deformation = (pressure_per_vertex * side_factor)[:, np.newaxis] * inner_surface.vertex_normals
inner_surface.vertices = inner_surface.vertices - deformation
# 3) Create outer surface (offset by wall thickness)
inner_surface.fix_normals()
outer_surface = trimesh.Trimesh(
vertices=inner_surface.vertices + cfg.wall_thickness_mm * inner_surface.vertex_normals,
faces=inner_surface.faces.copy(),
process=True
)
# 4) Combine surfaces
# For a true solid, we'd need to stitch edges - simplified here
brace = trimesh.util.concatenate([inner_surface, outer_surface])
brace.merge_vertices()
valid_faces = brace.nondegenerate_faces()
brace.update_faces(valid_faces)
brace.fix_normals()
return brace
def export_stl(self, mesh: 'trimesh.Trimesh', output_path: str):
"""
Export mesh to STL file.
Args:
mesh: trimesh.Trimesh object
output_path: Path for output STL file
"""
mesh.export(output_path)
print(f"Exported brace to {output_path}")
print(f" Vertices: {len(mesh.vertices)}")
print(f" Faces: {len(mesh.faces)}")

View File

@@ -0,0 +1,177 @@
"""
Data models for unified spine landmark representation.
This is the "glue" that connects different model outputs to the brace generator.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
import numpy as np
@dataclass
class VertebraLandmark:
"""
Unified representation of a single vertebra's landmarks.
All coordinates are in pixels (can be converted to mm with pixel_spacing).
"""
# Vertebra level identifier (e.g., "T1", "T4", "L1", etc.) - None if unknown
level: Optional[str] = None
# Center point of vertebra [x, y] in pixels
centroid_px: np.ndarray = field(default_factory=lambda: np.zeros(2))
# Four corner points [top_left, top_right, bottom_right, bottom_left] shape (4, 2)
corners_px: Optional[np.ndarray] = None
# Upper endplate points [left, right] shape (2, 2)
endplate_upper_px: Optional[np.ndarray] = None
# Lower endplate points [left, right] shape (2, 2)
endplate_lower_px: Optional[np.ndarray] = None
# Orientation angle of vertebra in degrees (tilt in coronal plane)
orientation_deg: Optional[float] = None
# Detection confidence (0-1)
confidence: float = 1.0
# Additional metadata from source model
meta: Optional[Dict[str, Any]] = None
def compute_orientation(self) -> float:
"""Compute vertebra orientation from corners or endplates."""
if self.orientation_deg is not None:
return self.orientation_deg
# Try to compute from upper endplate
if self.endplate_upper_px is not None:
left, right = self.endplate_upper_px[0], self.endplate_upper_px[1]
dx = right[0] - left[0]
dy = right[1] - left[1]
angle = np.degrees(np.arctan2(dy, dx))
self.orientation_deg = angle
return angle
# Try to compute from corners (top-left to top-right)
if self.corners_px is not None:
top_left, top_right = self.corners_px[0], self.corners_px[1]
dx = top_right[0] - top_left[0]
dy = top_right[1] - top_left[1]
angle = np.degrees(np.arctan2(dy, dx))
self.orientation_deg = angle
return angle
return 0.0
def compute_centroid(self) -> np.ndarray:
"""Compute centroid from corners if not set."""
if self.corners_px is not None and np.all(self.centroid_px == 0):
self.centroid_px = np.mean(self.corners_px, axis=0)
return self.centroid_px
@dataclass
class Spine2D:
"""
Complete 2D spine representation from an X-ray.
Contains all detected vertebrae and computed angles.
"""
# List of vertebrae, ordered from top (C7/T1) to bottom (L5/S1)
vertebrae: List[VertebraLandmark] = field(default_factory=list)
# Pixel spacing in mm [sx, sy] - from DICOM if available
pixel_spacing_mm: Optional[np.ndarray] = None
# Original image shape (height, width)
image_shape: Optional[tuple] = None
# Computed Cobb angles in degrees (individual fields)
cobb_pt: Optional[float] = None # Proximal Thoracic
cobb_mt: Optional[float] = None # Main Thoracic
cobb_tl: Optional[float] = None # Thoracolumbar/Lumbar
# Cobb angles as dictionary (alternative format)
cobb_angles: Optional[Dict[str, float]] = None # {'PT': angle, 'MT': angle, 'TL': angle}
# Curve type: "S" (double curve) or "C" (single curve) or "Normal"
curve_type: Optional[str] = None
# Rigo-Chêneau classification
rigo_type: Optional[str] = None # A1, A2, A3, B1, B2, C1, C2, E1, E2, Normal
rigo_description: Optional[str] = None # Detailed description
# Source model that generated this data
source_model: Optional[str] = None
# Additional metadata
meta: Optional[Dict[str, Any]] = None
def get_cobb_angles(self) -> Dict[str, float]:
"""Get Cobb angles as dictionary, preferring computed individual fields."""
# Prefer individual fields (set by compute_cobb_angles) over dictionary
# This ensures consistency between displayed values and classification
if self.cobb_pt is not None or self.cobb_mt is not None or self.cobb_tl is not None:
return {
'PT': self.cobb_pt or 0.0,
'MT': self.cobb_mt or 0.0,
'TL': self.cobb_tl or 0.0
}
if self.cobb_angles is not None:
return self.cobb_angles
return {'PT': 0.0, 'MT': 0.0, 'TL': 0.0}
def get_centroids(self) -> np.ndarray:
"""Get array of all vertebra centroids, shape (N, 2)."""
centroids = []
for v in self.vertebrae:
v.compute_centroid()
centroids.append(v.centroid_px)
return np.array(centroids, dtype=np.float32)
def get_orientations(self) -> np.ndarray:
"""Get array of all vertebra orientations in degrees, shape (N,)."""
return np.array([v.compute_orientation() for v in self.vertebrae], dtype=np.float32)
def to_mm(self, coords_px: np.ndarray) -> np.ndarray:
"""Convert pixel coordinates to millimeters."""
if self.pixel_spacing_mm is None:
# Default assumption: 0.25 mm/pixel (typical for spine X-rays)
spacing = np.array([0.25, 0.25])
else:
spacing = self.pixel_spacing_mm
return coords_px * spacing
def sort_vertebrae(self):
"""Sort vertebrae by vertical position (top to bottom)."""
self.vertebrae.sort(key=lambda v: float(v.centroid_px[1]))
@dataclass
class BraceConfig:
"""
Configuration parameters for brace generation.
"""
# Brace dimensions
brace_height_mm: float = 400.0 # Total height of brace
wall_thickness_mm: float = 4.0 # Shell thickness
# Torso shape parameters (for average body mode)
torso_width_mm: float = 280.0 # Left-right diameter at widest
torso_depth_mm: float = 200.0 # Front-back diameter at widest
# Correction parameters
pressure_strength_mm: float = 15.0 # Max indentation at apex
pressure_spread_deg: float = 45.0 # Angular spread of pressure zone
# Mesh resolution
n_vertical_slices: int = 100 # Number of cross-sections
n_circumference_points: int = 72 # Points per cross-section (every 5°)
# Opening (for brace accessibility)
front_opening_deg: float = 60.0 # Angular width of front opening (0 = closed)
# Mode
use_body_scan: bool = False # True = use 3D body scan, False = average body
body_scan_path: Optional[str] = None # Path to body scan mesh
# Scale
pixel_spacing_mm: Optional[np.ndarray] = None # Override pixel spacing

View File

@@ -0,0 +1,115 @@
"""
Image loader supporting JPEG, PNG, and DICOM formats.
"""
import numpy as np
from pathlib import Path
from typing import Tuple, Optional
try:
import pydicom
HAS_PYDICOM = True
except ImportError:
HAS_PYDICOM = False
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
try:
import cv2
HAS_CV2 = True
except ImportError:
HAS_CV2 = False
def load_xray(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""
Load an X-ray image from file.
Supports: JPEG, PNG, BMP, DICOM (.dcm)
Args:
path: Path to the image file
Returns:
img_u8: Grayscale image as uint8 array (H, W)
spacing_mm: Pixel spacing [sx, sy] in mm, or None if not available
"""
path = Path(path)
suffix = path.suffix.lower()
# DICOM
if suffix in ['.dcm', '.dicom']:
if not HAS_PYDICOM:
raise ImportError("pydicom is required for DICOM files. Install with: pip install pydicom")
return _load_dicom(str(path))
# Standard image formats
if suffix in ['.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff']:
return _load_standard_image(str(path))
# Try to load as standard image anyway
try:
return _load_standard_image(str(path))
except Exception as e:
raise ValueError(f"Could not load image: {path}. Error: {e}")
def _load_dicom(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""Load DICOM file."""
ds = pydicom.dcmread(path)
arr = ds.pixel_array.astype(np.float32)
# Apply modality LUT if present
if hasattr(ds, 'RescaleSlope') and hasattr(ds, 'RescaleIntercept'):
arr = arr * ds.RescaleSlope + ds.RescaleIntercept
# Normalize to 0-255
arr = arr - arr.min()
if arr.max() > 0:
arr = arr / arr.max()
img_u8 = (arr * 255).astype(np.uint8)
# Get pixel spacing
spacing_mm = None
if hasattr(ds, 'PixelSpacing'):
# PixelSpacing is [row_spacing, col_spacing] in mm
sy, sx = [float(x) for x in ds.PixelSpacing]
spacing_mm = np.array([sx, sy], dtype=np.float32)
elif hasattr(ds, 'ImagerPixelSpacing'):
sy, sx = [float(x) for x in ds.ImagerPixelSpacing]
spacing_mm = np.array([sx, sy], dtype=np.float32)
return img_u8, spacing_mm
def _load_standard_image(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""Load standard image format (JPEG, PNG, etc.)."""
if HAS_CV2:
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
raise ValueError(f"Could not read image: {path}")
return img.astype(np.uint8), None
elif HAS_PIL:
img = Image.open(path).convert('L') # Convert to grayscale
return np.array(img, dtype=np.uint8), None
else:
raise ImportError("Either opencv-python or Pillow is required. Install with: pip install opencv-python")
def load_xray_rgb(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""
Load X-ray as RGB (for models that expect 3-channel input).
Returns:
img_rgb: RGB image as uint8 array (H, W, 3)
spacing_mm: Pixel spacing or None
"""
img_gray, spacing = load_xray(path)
# Convert grayscale to RGB by stacking
img_rgb = np.stack([img_gray, img_gray, img_gray], axis=-1)
return img_rgb, spacing

346
brace-generator/pipeline.py Normal file
View File

@@ -0,0 +1,346 @@
"""
Complete pipeline: X-ray → Landmarks → Brace STL
"""
import numpy as np
from pathlib import Path
from typing import Optional, Dict, Any, Union
import json
from brace_generator.data_models import Spine2D, BraceConfig, VertebraLandmark
from brace_generator.image_loader import load_xray, load_xray_rgb
from brace_generator.adapters import BaseLandmarkAdapter, ScolioVisAdapter, VertLandmarkAdapter
from brace_generator.spine_analysis import (
compute_spine_curve, compute_cobb_angles, find_apex_vertebrae,
get_curve_severity, classify_rigo_type
)
from brace_generator.brace_surface import BraceGenerator
class BracePipeline:
"""
End-to-end pipeline for generating scoliosis braces from X-rays.
Usage:
# Basic usage with default model
pipeline = BracePipeline()
pipeline.process("xray.png", "brace.stl")
# With specific model
pipeline = BracePipeline(model="vertebra-landmark")
pipeline.process("xray.dcm", "brace.stl")
# With body scan
config = BraceConfig(use_body_scan=True, body_scan_path="body.obj")
pipeline = BracePipeline(config=config)
pipeline.process("xray.png", "brace.stl")
"""
AVAILABLE_MODELS = {
'scoliovis': ScolioVisAdapter,
'vertebra-landmark': VertLandmarkAdapter,
}
def __init__(
self,
model: str = 'scoliovis',
config: Optional[BraceConfig] = None,
device: str = 'cpu'
):
"""
Initialize pipeline.
Args:
model: Model to use ('scoliovis' or 'vertebra-landmark')
config: Brace configuration
device: 'cpu' or 'cuda'
"""
self.device = device
self.config = config or BraceConfig()
self.model_name = model.lower()
# Initialize model adapter
if self.model_name not in self.AVAILABLE_MODELS:
raise ValueError(f"Unknown model: {model}. Available: {list(self.AVAILABLE_MODELS.keys())}")
self.adapter: BaseLandmarkAdapter = self.AVAILABLE_MODELS[self.model_name](device=device)
self.brace_generator = BraceGenerator(self.config)
# Store last results for inspection
self.last_spine: Optional[Spine2D] = None
self.last_image: Optional[np.ndarray] = None
def process(
self,
xray_path: str,
output_stl_path: str,
visualize: bool = False,
save_landmarks: bool = False
) -> Dict[str, Any]:
"""
Process X-ray and generate brace STL.
Args:
xray_path: Path to input X-ray (JPEG, PNG, or DICOM)
output_stl_path: Path for output STL file
visualize: If True, also save visualization image
save_landmarks: If True, also save landmarks JSON
Returns:
Dictionary with analysis results
"""
print(f"=" * 60)
print(f"Brace Generation Pipeline")
print(f"Model: {self.adapter.name}")
print(f"=" * 60)
# 1) Load X-ray
print(f"\n1. Loading X-ray: {xray_path}")
image_rgb, pixel_spacing = load_xray_rgb(xray_path)
self.last_image = image_rgb
print(f" Image size: {image_rgb.shape[:2]}")
if pixel_spacing is not None:
print(f" Pixel spacing: {pixel_spacing} mm")
# 2) Detect landmarks
print(f"\n2. Detecting landmarks...")
spine = self.adapter.predict(image_rgb)
spine.pixel_spacing_mm = pixel_spacing
self.last_spine = spine
print(f" Detected {len(spine.vertebrae)} vertebrae")
if len(spine.vertebrae) < 5:
raise ValueError(f"Insufficient vertebrae detected ({len(spine.vertebrae)}). Need at least 5.")
# 3) Compute spine analysis
print(f"\n3. Analyzing spine curvature...")
compute_cobb_angles(spine)
apexes = find_apex_vertebrae(spine)
# Classify Rigo type
rigo_result = classify_rigo_type(spine)
print(f" Cobb Angles:")
print(f" PT (Proximal Thoracic): {spine.cobb_pt:.1f}° - {get_curve_severity(spine.cobb_pt)}")
print(f" MT (Main Thoracic): {spine.cobb_mt:.1f}° - {get_curve_severity(spine.cobb_mt)}")
print(f" TL (Thoracolumbar): {spine.cobb_tl:.1f}° - {get_curve_severity(spine.cobb_tl)}")
print(f" Curve type: {spine.curve_type}")
print(f" Rigo Classification: {rigo_result['rigo_type']}")
print(f" - {rigo_result['description']}")
print(f" Apex vertebrae indices: {apexes}")
# 4) Generate brace
print(f"\n4. Generating brace mesh...")
if self.config.use_body_scan:
print(f" Mode: Using body scan ({self.config.body_scan_path})")
else:
print(f" Mode: Average body shape")
brace_mesh = self.brace_generator.generate(spine)
print(f" Mesh: {len(brace_mesh.vertices)} vertices, {len(brace_mesh.faces)} faces")
# 5) Export STL
print(f"\n5. Exporting STL: {output_stl_path}")
self.brace_generator.export_stl(brace_mesh, output_stl_path)
# 6) Optional: Save visualization
if visualize:
vis_path = str(Path(output_stl_path).with_suffix('.png'))
self._save_visualization(vis_path, spine, image_rgb)
print(f" Visualization saved: {vis_path}")
# 7) Optional: Save landmarks JSON
if save_landmarks:
json_path = str(Path(output_stl_path).with_suffix('.json'))
self._save_landmarks_json(json_path, spine)
print(f" Landmarks saved: {json_path}")
# Prepare results
results = {
'input_image': xray_path,
'output_stl': output_stl_path,
'model': self.adapter.name,
'vertebrae_detected': len(spine.vertebrae),
'cobb_angles': {
'PT': spine.cobb_pt,
'MT': spine.cobb_mt,
'TL': spine.cobb_tl,
},
'curve_type': spine.curve_type,
'rigo_type': rigo_result['rigo_type'],
'rigo_description': rigo_result['description'],
'apex_indices': apexes,
'mesh_vertices': len(brace_mesh.vertices),
'mesh_faces': len(brace_mesh.faces),
}
print(f"\n{'=' * 60}")
print(f"Pipeline complete!")
print(f"{'=' * 60}")
return results
def _save_visualization(self, path: str, spine: Spine2D, image: np.ndarray):
"""Save visualization of detected landmarks and spine curve."""
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
except ImportError:
print(" Warning: matplotlib not available for visualization")
return
fig, axes = plt.subplots(1, 2, figsize=(14, 10))
# Left: Original with landmarks
ax1 = axes[0]
ax1.imshow(image)
# Draw vertebra centers
centroids = spine.get_centroids()
ax1.scatter(centroids[:, 0], centroids[:, 1], c='red', s=30, zorder=5)
# Draw corners if available
for vert in spine.vertebrae:
if vert.corners_px is not None:
corners = vert.corners_px
# Draw quadrilateral
for i in range(4):
j = (i + 1) % 4
ax1.plot([corners[i, 0], corners[j, 0]],
[corners[i, 1], corners[j, 1]], 'g-', linewidth=1)
ax1.set_title(f"Detected Landmarks ({len(spine.vertebrae)} vertebrae)")
ax1.axis('off')
# Right: Spine curve analysis
ax2 = axes[1]
ax2.imshow(image, alpha=0.5)
# Draw spine curve
try:
C, T, N, curv = compute_spine_curve(spine)
ax2.plot(C[:, 0], C[:, 1], 'b-', linewidth=2, label='Spine curve')
# Highlight high curvature regions
high_curv_mask = curv > curv.mean() + curv.std()
ax2.scatter(C[high_curv_mask, 0], C[high_curv_mask, 1],
c='orange', s=20, label='High curvature')
except:
pass
# Get Rigo classification for display
rigo_result = classify_rigo_type(spine)
# Add Cobb angles and Rigo type text
text = f"Cobb Angles:\n"
text += f"PT: {spine.cobb_pt:.1f}°\n"
text += f"MT: {spine.cobb_mt:.1f}°\n"
text += f"TL: {spine.cobb_tl:.1f}°\n"
text += f"Curve: {spine.curve_type}\n"
text += f"-----------\n"
text += f"Rigo: {rigo_result['rigo_type']}"
ax2.text(0.02, 0.98, text, transform=ax2.transAxes, fontsize=10,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
ax2.set_title("Spine Analysis")
ax2.axis('off')
ax2.legend(loc='lower right')
plt.tight_layout()
plt.savefig(path, dpi=150, bbox_inches='tight')
plt.close()
def _save_landmarks_json(self, path: str, spine: Spine2D):
"""Save landmarks to JSON file with Rigo classification."""
def to_native(val):
"""Convert numpy types to native Python types."""
if isinstance(val, np.ndarray):
return val.tolist()
elif isinstance(val, (np.float32, np.float64)):
return float(val)
elif isinstance(val, (np.int32, np.int64)):
return int(val)
return val
# Get Rigo classification
rigo_result = classify_rigo_type(spine)
data = {
'source_model': spine.source_model,
'image_shape': list(spine.image_shape) if spine.image_shape else None,
'pixel_spacing_mm': spine.pixel_spacing_mm.tolist() if spine.pixel_spacing_mm is not None else None,
'cobb_angles': {
'PT': to_native(spine.cobb_pt),
'MT': to_native(spine.cobb_mt),
'TL': to_native(spine.cobb_tl),
},
'curve_type': spine.curve_type,
'rigo_classification': {
'type': rigo_result['rigo_type'],
'description': rigo_result['description'],
'curve_pattern': rigo_result['curve_pattern'],
'n_significant_curves': rigo_result['n_significant_curves'],
},
'vertebrae': []
}
for vert in spine.vertebrae:
vert_data = {
'level': vert.level,
'centroid_px': vert.centroid_px.tolist(),
'orientation_deg': to_native(vert.orientation_deg),
'confidence': to_native(vert.confidence),
}
if vert.corners_px is not None:
vert_data['corners_px'] = vert.corners_px.tolist()
data['vertebrae'].append(vert_data)
with open(path, 'w') as f:
json.dump(data, f, indent=2)
def main():
"""Command-line interface for brace generation."""
import argparse
parser = argparse.ArgumentParser(description='Generate scoliosis brace from X-ray')
parser.add_argument('input', help='Input X-ray image (JPEG, PNG, or DICOM)')
parser.add_argument('output', help='Output STL file path')
parser.add_argument('--model', choices=['scoliovis', 'vertebra-landmark'],
default='scoliovis', help='Landmark detection model')
parser.add_argument('--device', default='cpu', help='Device (cpu or cuda)')
parser.add_argument('--body-scan', help='Path to 3D body scan mesh (optional)')
parser.add_argument('--visualize', action='store_true', help='Save visualization')
parser.add_argument('--save-landmarks', action='store_true', help='Save landmarks JSON')
parser.add_argument('--pressure', type=float, default=15.0,
help='Pressure strength in mm (default: 15)')
parser.add_argument('--thickness', type=float, default=4.0,
help='Wall thickness in mm (default: 4)')
args = parser.parse_args()
# Build config
config = BraceConfig(
pressure_strength_mm=args.pressure,
wall_thickness_mm=args.thickness,
)
if args.body_scan:
config.use_body_scan = True
config.body_scan_path = args.body_scan
# Run pipeline
pipeline = BracePipeline(model=args.model, config=config, device=args.device)
results = pipeline.process(
args.input,
args.output,
visualize=args.visualize,
save_landmarks=args.save_landmarks
)
return results
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,464 @@
"""
Spine analysis functions for computing curves, Cobb angles, and identifying apex vertebrae.
"""
import numpy as np
from scipy.interpolate import splprep, splev
from typing import Tuple, List, Optional
from data_models import Spine2D, VertebraLandmark
def compute_spine_curve(
spine: Spine2D,
smooth: float = 1.0,
n_samples: int = 200
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Compute smooth spine centerline from vertebra centroids.
Args:
spine: Spine2D object with detected vertebrae
smooth: Smoothing factor for spline (higher = smoother)
n_samples: Number of points to sample along the curve
Returns:
C: Curve points, shape (n_samples, 2)
T: Tangent vectors, shape (n_samples, 2)
N: Normal vectors, shape (n_samples, 2)
curvature: Curvature at each point, shape (n_samples,)
"""
pts = spine.get_centroids()
if len(pts) < 4:
raise ValueError(f"Need at least 4 vertebrae for spline, got {len(pts)}")
# Fit parametric spline through centroids
x = pts[:, 0]
y = pts[:, 1]
try:
tck, u = splprep([x, y], s=smooth, k=min(3, len(pts)-1))
except Exception as e:
# Fallback: simple linear interpolation
t = np.linspace(0, 1, n_samples)
xs = np.interp(t, np.linspace(0, 1, len(x)), x)
ys = np.interp(t, np.linspace(0, 1, len(y)), y)
C = np.stack([xs, ys], axis=1).astype(np.float32)
T = np.gradient(C, axis=0)
T = T / (np.linalg.norm(T, axis=1, keepdims=True) + 1e-8)
N = np.stack([-T[:, 1], T[:, 0]], axis=1)
curvature = np.zeros(n_samples, dtype=np.float32)
return C, T, N, curvature
# Sample the spline
u_new = np.linspace(0, 1, n_samples)
xs, ys = splev(u_new, tck)
# First and second derivatives
dx, dy = splev(u_new, tck, der=1)
ddx, ddy = splev(u_new, tck, der=2)
# Curve points
C = np.stack([xs, ys], axis=1).astype(np.float32)
# Tangent vectors (normalized)
T = np.stack([dx, dy], axis=1)
T_norm = np.linalg.norm(T, axis=1, keepdims=True) + 1e-8
T = (T / T_norm).astype(np.float32)
# Normal vectors (perpendicular to tangent)
N = np.stack([-T[:, 1], T[:, 0]], axis=1).astype(np.float32)
# Curvature: |x'y'' - y'x''| / (x'^2 + y'^2)^(3/2)
curvature = np.abs(dx * ddy - dy * ddx) / (np.power(dx**2 + dy**2, 1.5) + 1e-8)
curvature = curvature.astype(np.float32)
return C, T, N, curvature
def compute_cobb_angles(spine: Spine2D) -> Tuple[float, float, float]:
"""
Compute Cobb angles from vertebra orientations.
The Cobb angle is measured as the angle between:
- The superior endplate of the most tilted vertebra at the top of a curve
- The inferior endplate of the most tilted vertebra at the bottom
This function estimates 3 Cobb angles:
- PT: Proximal Thoracic (T1-T6 region)
- MT: Main Thoracic (T6-T12 region)
- TL: Thoracolumbar/Lumbar (T12-L5 region)
Args:
spine: Spine2D object with detected vertebrae
Returns:
(pt_angle, mt_angle, tl_angle) in degrees
"""
orientations = spine.get_orientations()
n = len(orientations)
if n < 7:
spine.cobb_pt = 0.0
spine.cobb_mt = 0.0
spine.cobb_tl = 0.0
return 0.0, 0.0, 0.0
# Divide into regions (approximately)
# PT: top 1/3, MT: middle 1/3, TL: bottom 1/3
pt_end = n // 3
mt_end = 2 * n // 3
# Find max tilt difference in each region
def region_cobb(start_idx: int, end_idx: int) -> float:
if end_idx <= start_idx:
return 0.0
region_angles = orientations[start_idx:end_idx]
if len(region_angles) < 2:
return 0.0
# Cobb angle = max angle - min angle in region
return abs(float(np.max(region_angles) - np.min(region_angles)))
pt_angle = region_cobb(0, pt_end)
mt_angle = region_cobb(pt_end, mt_end)
tl_angle = region_cobb(mt_end, n)
# Store in spine object
spine.cobb_pt = pt_angle
spine.cobb_mt = mt_angle
spine.cobb_tl = tl_angle
# Determine curve type
if mt_angle > 10 and tl_angle > 10:
spine.curve_type = "S" # Double curve
elif mt_angle > 10 or tl_angle > 10:
spine.curve_type = "C" # Single curve
else:
spine.curve_type = "Normal"
return pt_angle, mt_angle, tl_angle
def find_apex_vertebrae(spine: Spine2D) -> List[int]:
"""
Find indices of apex vertebrae (most deviated from midline).
Args:
spine: Spine2D with computed curve
Returns:
List of vertebra indices that are curve apexes
"""
centroids = spine.get_centroids()
if len(centroids) < 5:
return []
# Find midline (linear fit through endpoints)
start = centroids[0]
end = centroids[-1]
# Distance from midline for each vertebra
midline_vec = end - start
midline_len = np.linalg.norm(midline_vec)
if midline_len < 1e-6:
return []
midline_unit = midline_vec / midline_len
# Calculate perpendicular distance to midline
deviations = []
for i, pt in enumerate(centroids):
v = pt - start
# Project onto midline
proj_len = np.dot(v, midline_unit)
proj = proj_len * midline_unit
# Perpendicular distance
perp = v - proj
dist = np.linalg.norm(perp)
# Sign: positive if to the right of midline
sign = np.sign(np.cross(midline_unit, v / (np.linalg.norm(v) + 1e-8)))
deviations.append(dist * sign)
deviations = np.array(deviations)
# Find local extrema (peaks and valleys)
apexes = []
for i in range(1, len(deviations) - 1):
# Local maximum
if deviations[i] > deviations[i-1] and deviations[i] > deviations[i+1]:
if abs(deviations[i]) > 5: # Minimum deviation threshold (pixels)
apexes.append(i)
# Local minimum
elif deviations[i] < deviations[i-1] and deviations[i] < deviations[i+1]:
if abs(deviations[i]) > 5:
apexes.append(i)
return apexes
def get_curve_severity(cobb_angle: float) -> str:
"""
Get clinical severity classification from Cobb angle.
Args:
cobb_angle: Cobb angle in degrees
Returns:
Severity string: "Normal", "Mild", "Moderate", or "Severe"
"""
if cobb_angle < 10:
return "Normal"
elif cobb_angle < 25:
return "Mild"
elif cobb_angle < 40:
return "Moderate"
else:
return "Severe"
def classify_rigo_type(spine: Spine2D) -> dict:
"""
Classify scoliosis according to Rigo-Chêneau brace classification.
Rigo Classification Types:
- A1, A2, A3: 3-curve patterns (thoracic major)
- B1, B2: 4-curve patterns (double major)
- C1, C2: Single thoracolumbar/lumbar
- E1, E2: Single thoracic
Args:
spine: Spine2D object with detected vertebrae and Cobb angles
Returns:
dict with 'rigo_type', 'description', 'apex_region', 'curve_pattern'
"""
# Get Cobb angles
cobb_angles = spine.get_cobb_angles()
pt = cobb_angles.get('PT', 0)
mt = cobb_angles.get('MT', 0)
tl = cobb_angles.get('TL', 0)
n_verts = len(spine.vertebrae)
# Calculate lateral deviations to determine curve direction
centroids = spine.get_centroids()
deviations = _calculate_lateral_deviations(centroids)
# Find apex positions and directions
apex_info = _find_apex_info(centroids, deviations, n_verts)
# Determine curve pattern based on number of significant curves
significant_curves = []
if pt >= 10:
significant_curves.append(('PT', pt))
if mt >= 10:
significant_curves.append(('MT', mt))
if tl >= 10:
significant_curves.append(('TL', tl))
n_curves = len(significant_curves)
# Classification logic
rigo_type = "N/A"
description = ""
curve_pattern = ""
# No significant scoliosis
if n_curves == 0 or max(pt, mt, tl) < 10:
rigo_type = "Normal"
description = "No significant scoliosis (all Cobb angles < 10°)"
curve_pattern = "None"
# Single curve patterns
elif n_curves == 1:
max_curve = significant_curves[0][0]
max_angle = significant_curves[0][1]
if max_curve == 'MT' or max_curve == 'PT':
# Thoracic single curve
if apex_info['thoracic_apex_idx'] is not None:
# Check if there's a compensatory lumbar
if tl > 5:
rigo_type = "E2"
description = f"Single thoracic curve ({max_angle:.1f}°) with lumbar compensatory ({tl:.1f}°)"
curve_pattern = "Thoracic with compensation"
else:
rigo_type = "E1"
description = f"True single thoracic curve ({max_angle:.1f}°)"
curve_pattern = "Single thoracic"
else:
rigo_type = "E1"
description = f"Single thoracic curve ({max_angle:.1f}°)"
curve_pattern = "Single thoracic"
elif max_curve == 'TL':
# Thoracolumbar/Lumbar single curve
if mt > 5 or pt > 5:
rigo_type = "C2"
description = f"Thoracolumbar curve ({tl:.1f}°) with upper compensatory"
curve_pattern = "TL/L with compensation"
else:
rigo_type = "C1"
description = f"Single thoracolumbar/lumbar curve ({tl:.1f}°)"
curve_pattern = "Single TL/L"
# Double curve patterns
elif n_curves >= 2:
# Determine which curves are primary
thoracic_total = pt + mt
lumbar_total = tl
# Check curve directions for S vs C pattern
is_s_curve = apex_info['is_s_pattern']
if is_s_curve:
# S-curve: typically 3 or 4 curve patterns
if thoracic_total > lumbar_total * 1.5:
# Thoracic dominant - Type A (3-curve)
if apex_info['lumbar_apex_low']:
rigo_type = "A1"
description = f"3-curve: Thoracic major ({mt:.1f}°), lumbar apex low"
elif apex_info['apex_at_tl_junction']:
rigo_type = "A2"
description = f"3-curve: Thoracolumbar transition ({mt:.1f}°/{tl:.1f}°)"
else:
rigo_type = "A3"
description = f"3-curve: Thoracic major ({mt:.1f}°) with structural lumbar ({tl:.1f}°)"
curve_pattern = "3-curve (thoracic major)"
elif lumbar_total > thoracic_total * 1.5:
# Lumbar dominant
rigo_type = "C2"
description = f"Lumbar major ({tl:.1f}°) with thoracic compensatory ({mt:.1f}°)"
curve_pattern = "Lumbar major"
else:
# Double major - Type B (4-curve)
if tl >= mt:
rigo_type = "B1"
description = f"4-curve: Double major, lumbar prominent ({tl:.1f}°/{mt:.1f}°)"
else:
rigo_type = "B2"
description = f"4-curve: Double major, thoracic prominent ({mt:.1f}°/{tl:.1f}°)"
curve_pattern = "4-curve (double major)"
else:
# C-curve pattern (curves in same direction)
if mt >= tl:
if tl > 5:
rigo_type = "A3"
description = f"Long thoracic curve ({mt:.1f}°) extending to lumbar ({tl:.1f}°)"
else:
rigo_type = "E2"
description = f"Thoracic curve ({mt:.1f}°) with minor lumbar ({tl:.1f}°)"
curve_pattern = "Extended thoracic"
else:
rigo_type = "C2"
description = f"TL/Lumbar curve ({tl:.1f}°) with thoracic involvement ({mt:.1f}°)"
curve_pattern = "Extended lumbar"
# Store in spine object
spine.rigo_type = rigo_type
spine.rigo_description = description
return {
'rigo_type': rigo_type,
'description': description,
'curve_pattern': curve_pattern,
'apex_info': apex_info,
'cobb_angles': cobb_angles,
'n_significant_curves': n_curves
}
def _calculate_lateral_deviations(centroids: np.ndarray) -> np.ndarray:
"""Calculate lateral deviation from midline for each vertebra."""
if len(centroids) < 2:
return np.zeros(len(centroids))
# Midline from first to last vertebra
start = centroids[0]
end = centroids[-1]
midline_vec = end - start
midline_len = np.linalg.norm(midline_vec)
if midline_len < 1e-6:
return np.zeros(len(centroids))
midline_unit = midline_vec / midline_len
deviations = []
for pt in centroids:
v = pt - start
# Project onto midline
proj_len = np.dot(v, midline_unit)
proj = proj_len * midline_unit
# Perpendicular vector
perp = v - proj
dist = np.linalg.norm(perp)
# Sign: positive = right, negative = left
sign = np.sign(np.cross(midline_unit, perp / (dist + 1e-8)))
deviations.append(dist * sign)
return np.array(deviations)
def _find_apex_info(centroids: np.ndarray, deviations: np.ndarray, n_verts: int) -> dict:
"""Find apex positions and determine curve pattern."""
info = {
'thoracic_apex_idx': None,
'lumbar_apex_idx': None,
'lumbar_apex_low': False,
'apex_at_tl_junction': False,
'is_s_pattern': False,
'apex_directions': []
}
if len(deviations) < 3:
return info
# Find local extrema (apexes)
apexes = []
apex_values = []
for i in range(1, len(deviations) - 1):
if (deviations[i] > deviations[i-1] and deviations[i] > deviations[i+1]) or \
(deviations[i] < deviations[i-1] and deviations[i] < deviations[i+1]):
if abs(deviations[i]) > 3: # Minimum threshold
apexes.append(i)
apex_values.append(deviations[i])
# Determine S-pattern (alternating signs at apexes)
if len(apex_values) >= 2:
signs = [np.sign(v) for v in apex_values]
# S-pattern if adjacent apexes have opposite signs
for i in range(len(signs) - 1):
if signs[i] != signs[i+1]:
info['is_s_pattern'] = True
break
# Classify apex regions
# Assume: top 40% = thoracic, middle 20% = TL junction, bottom 40% = lumbar
thoracic_end = int(0.4 * n_verts)
tl_junction_end = int(0.6 * n_verts)
for apex_idx in apexes:
if apex_idx < thoracic_end:
if info['thoracic_apex_idx'] is None or \
abs(deviations[apex_idx]) > abs(deviations[info['thoracic_apex_idx']]):
info['thoracic_apex_idx'] = apex_idx
elif apex_idx < tl_junction_end:
info['apex_at_tl_junction'] = True
else:
if info['lumbar_apex_idx'] is None or \
abs(deviations[apex_idx]) > abs(deviations[info['lumbar_apex_idx']]):
info['lumbar_apex_idx'] = apex_idx
# Check if lumbar apex is in lower region (bottom 30%)
if info['lumbar_apex_idx'] is not None:
if info['lumbar_apex_idx'] > int(0.7 * n_verts):
info['lumbar_apex_low'] = True
info['apex_directions'] = apex_values
return info

View File

@@ -16,8 +16,9 @@ RUN npm ci
# Copy source code # Copy source code
COPY . . COPY . .
# Build the app (uses relative API URLs) # Build the app (uses relative API URLs for all API calls)
ENV VITE_API_URL="" ENV VITE_API_URL=""
ENV VITE_API_BASE=""
RUN npm run build RUN npm run build
# Stage 2: Serve with nginx # Stage 2: Serve with nginx

View File

@@ -16,8 +16,24 @@ server {
# Increase max body size for file uploads # Increase max body size for file uploads
client_max_body_size 100M; client_max_body_size 100M;
# CORS headers - allow all origins
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
# Proxy API requests to the API container # Proxy API requests to the API container
location /api/ { location /api/ {
# Handle preflight OPTIONS requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://api:3002/api/; proxy_pass http://api:3002/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -29,6 +45,11 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s; proxy_read_timeout 300s;
proxy_connect_timeout 75s; proxy_connect_timeout 75s;
# Add CORS headers to response
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
} }
# Proxy file requests to the API container # Proxy file requests to the API container
@@ -39,6 +60,11 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s; proxy_read_timeout 300s;
# Add CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
} }
# Serve static assets with caching # Serve static assets with caching

View File

@@ -8,6 +8,11 @@ import CaseDetailPage from "./pages/CaseDetail";
import PipelineCaseDetail from "./pages/PipelineCaseDetail"; import PipelineCaseDetail from "./pages/PipelineCaseDetail";
import ShellEditorPage from "./pages/ShellEditorPage"; import ShellEditorPage from "./pages/ShellEditorPage";
// Patient pages
import PatientList from "./pages/PatientList";
import PatientDetail from "./pages/PatientDetail";
import PatientForm from "./pages/PatientForm";
// Admin pages // Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard"; import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminUsers from "./pages/admin/AdminUsers"; import AdminUsers from "./pages/admin/AdminUsers";
@@ -110,6 +115,48 @@ function AppRoutes() {
} }
/> />
{/* Patient routes */}
<Route
path="/patients"
element={
<ProtectedRoute>
<AppShell>
<PatientList />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/new"
element={
<ProtectedRoute>
<AppShell>
<PatientForm />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/:patientId"
element={
<ProtectedRoute>
<AppShell>
<PatientDetail />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/:patientId/edit"
element={
<ProtectedRoute>
<AppShell>
<PatientForm />
</AppShell>
</ProtectedRoute>
}
/>
{/* Admin routes */} {/* Admin routes */}
<Route <Route
path="/admin" path="/admin"

View File

@@ -5,7 +5,7 @@
import { getAuthHeaders } from "../context/AuthContext"; import { getAuthHeaders } from "../context/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api"; const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> { async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`; const url = `${API_BASE}${path}`;
@@ -101,6 +101,8 @@ export type AdminCase = {
analysis_result: any; analysis_result: any;
landmarks_data: any; landmarks_data: any;
body_scan_path: string | null; body_scan_path: string | null;
is_archived?: boolean;
archived_at?: string | null;
created_by: number | null; created_by: number | null;
created_by_username: string | null; created_by_username: string | null;
created_at: string; created_at: string;
@@ -122,6 +124,8 @@ export async function listCasesAdmin(params?: {
offset?: number; offset?: number;
sortBy?: string; sortBy?: string;
sortOrder?: "ASC" | "DESC"; sortOrder?: "ASC" | "DESC";
includeArchived?: boolean;
archivedOnly?: boolean;
}): Promise<ListCasesResponse> { }): Promise<ListCasesResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status); if (params?.status) searchParams.set("status", params.status);
@@ -131,11 +135,31 @@ export async function listCasesAdmin(params?: {
if (params?.offset) searchParams.set("offset", params.offset.toString()); if (params?.offset) searchParams.set("offset", params.offset.toString());
if (params?.sortBy) searchParams.set("sortBy", params.sortBy); if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder); if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params?.includeArchived) searchParams.set("includeArchived", "true");
if (params?.archivedOnly) searchParams.set("archivedOnly", "true");
const query = searchParams.toString(); const query = searchParams.toString();
return adminFetch(`/admin/cases${query ? `?${query}` : ""}`); return adminFetch(`/admin/cases${query ? `?${query}` : ""}`);
} }
/**
* Restore (unarchive) a case - admin only
*/
export async function restoreCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return adminFetch(`/cases/${encodeURIComponent(caseId)}/unarchive`, {
method: "POST",
});
}
/**
* Restore (unarchive) a patient - admin only
*/
export async function restorePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return adminFetch(`/patients/${patientId}/unarchive`, {
method: "POST",
});
}
// ============================================ // ============================================
// ANALYTICS // ANALYTICS
// ============================================ // ============================================
@@ -234,3 +258,145 @@ export async function getAuditLog(params?: {
const query = searchParams.toString(); const query = searchParams.toString();
return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`); return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`);
} }
// ============================================
// API REQUEST ACTIVITY LOG
// ============================================
export type FileUploadInfo = {
fieldname: string;
originalname: string;
mimetype: string;
size: number;
destination?: string;
filename?: string;
};
export type ResponseSummary = {
success?: boolean;
message?: string;
error?: string;
caseId?: string;
status?: string;
userId?: number;
username?: string;
tokenGenerated?: boolean;
rigoType?: string;
cobbAngles?: { PT?: number; MT?: number; TL?: number };
vertebraeDetected?: number;
bracesGenerated?: { regular: boolean; vase: boolean };
braceGenerated?: boolean;
braceVertices?: number;
filesGenerated?: string[];
outputUrl?: string;
landmarksCount?: number | string;
casesCount?: number;
usersCount?: number;
entriesCount?: number;
requestsCount?: number;
total?: number;
bodyScanUploaded?: boolean;
measurementsExtracted?: boolean;
errorCode?: number;
};
export type ApiRequestEntry = {
id: number;
user_id: number | null;
username: string | null;
method: string;
path: string;
route_pattern: string | null;
query_params: string | null;
request_params: string | null;
file_uploads: string | null;
status_code: number | null;
response_time_ms: number | null;
response_summary: string | null;
ip_address: string | null;
user_agent: string | null;
request_body_size: number | null;
response_body_size: number | null;
error_message: string | null;
created_at: string;
};
export type ApiRequestsResponse = {
requests: ApiRequestEntry[];
total: number;
limit: number;
offset: number;
};
export type ApiActivityStats = {
total: number;
byMethod: Record<string, number>;
byStatusCategory: Record<string, number>;
topEndpoints: {
method: string;
path: string;
count: number;
avg_response_time: number;
}[];
topUsers: {
user_id: number | null;
username: string | null;
count: number;
}[];
responseTime: {
avg: number;
min: number;
max: number;
};
requestsPerHour: {
hour: string;
count: number;
}[];
errorRate: number;
};
export async function getApiActivity(params?: {
userId?: number;
username?: string;
method?: string;
path?: string;
statusCode?: number;
statusCategory?: "2xx" | "3xx" | "4xx" | "5xx";
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}): Promise<ApiRequestsResponse> {
const searchParams = new URLSearchParams();
if (params?.userId) searchParams.set("userId", params.userId.toString());
if (params?.username) searchParams.set("username", params.username);
if (params?.method) searchParams.set("method", params.method);
if (params?.path) searchParams.set("path", params.path);
if (params?.statusCode) searchParams.set("statusCode", params.statusCode.toString());
if (params?.statusCategory) searchParams.set("statusCategory", params.statusCategory);
if (params?.startDate) searchParams.set("startDate", params.startDate);
if (params?.endDate) searchParams.set("endDate", params.endDate);
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
const query = searchParams.toString();
return adminFetch(`/admin/activity${query ? `?${query}` : ""}`);
}
export async function getApiActivityStats(params?: {
startDate?: string;
endDate?: string;
}): Promise<{ stats: ApiActivityStats }> {
const searchParams = new URLSearchParams();
if (params?.startDate) searchParams.set("startDate", params.startDate);
if (params?.endDate) searchParams.set("endDate", params.endDate);
const query = searchParams.toString();
return adminFetch(`/admin/activity/stats${query ? `?${query}` : ""}`);
}
export async function cleanupApiActivityLogs(daysToKeep: number = 30): Promise<{ message: string; deletedCount: number }> {
return adminFetch(`/admin/activity/cleanup?daysToKeep=${daysToKeep}`, {
method: "DELETE",
});
}

View File

@@ -1,5 +1,18 @@
export type CasePatient = {
id: number;
firstName: string;
lastName: string;
fullName: string;
mrn?: string | null;
dateOfBirth?: string | null;
gender?: string | null;
};
export type CaseRecord = { export type CaseRecord = {
caseId: string; caseId: string;
patient_id?: number | null;
patient?: CasePatient | null;
visit_date?: string | null;
status: string; status: string;
current_step: string | null; current_step: string | null;
created_at: string; created_at: string;
@@ -356,7 +369,28 @@ export async function analyzeXray(
} }
/** /**
* Delete a case and all associated files * Archive a case (soft delete - preserves all files)
*/
export async function archiveCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return await safeFetch<{ caseId: string; archived: boolean; message: string }>(
`/cases/${encodeURIComponent(caseId)}/archive`,
{ method: "POST" }
);
}
/**
* Unarchive a case (restore)
*/
export async function unarchiveCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return await safeFetch<{ caseId: string; archived: boolean; message: string }>(
`/cases/${encodeURIComponent(caseId)}/unarchive`,
{ method: "POST" }
);
}
/**
* Delete a case - DEPRECATED: Use archiveCase instead
* This now archives instead of deleting
*/ */
export async function deleteCase(caseId: string): Promise<{ message: string }> { export async function deleteCase(caseId: string): Promise<{ message: string }> {
return await safeFetch<{ message: string }>( return await safeFetch<{ message: string }>(

View File

@@ -0,0 +1,248 @@
/**
* Patient API Client
* API functions for patient management
*/
import { getAuthHeaders } from "../context/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function patientFetch<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...init?.headers,
},
});
const text = await response.text();
if (!response.ok) {
const error = text ? JSON.parse(text) : { message: "Request failed" };
throw new Error(error.message || `Request failed: ${response.status}`);
}
return text ? JSON.parse(text) : ({} as T);
}
// ============================================
// TYPES
// ============================================
export type Patient = {
id: number;
mrn: string | null;
first_name: string;
last_name: string;
date_of_birth: string | null;
gender: "male" | "female" | "other" | null;
email: string | null;
phone: string | null;
address: string | null;
diagnosis: string | null;
curve_type: string | null;
medical_history: string | null;
referring_physician: string | null;
insurance_info: string | null;
notes: string | null;
is_active: number;
created_by: number | null;
created_by_username: string | null;
created_at: string;
updated_at: string;
case_count?: number;
last_visit?: string | null;
};
export type PatientCase = {
case_id: string;
case_type: string;
status: string;
current_step: string | null;
visit_date: string | null;
notes: string | null;
analysis_result: any;
landmarks_data: any;
body_scan_path: string | null;
body_scan_url: string | null;
created_at: string;
updated_at: string;
};
export type PatientInput = {
mrn?: string;
firstName: string;
lastName: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
email?: string;
phone?: string;
address?: string;
diagnosis?: string;
curveType?: string;
medicalHistory?: string;
referringPhysician?: string;
insuranceInfo?: string;
notes?: string;
};
export type PatientListResponse = {
patients: Patient[];
total: number;
limit: number;
offset: number;
};
export type PatientStats = {
total: number;
active: number;
inactive: number;
withCases: number;
byGender: Record<string, number>;
recentPatients: number;
};
// ============================================
// API FUNCTIONS
// ============================================
/**
* Create a new patient
*/
export async function createPatient(data: PatientInput): Promise<{ patient: Patient }> {
return patientFetch("/patients", {
method: "POST",
body: JSON.stringify(data),
});
}
/**
* List patients with optional filters
*/
export async function listPatients(params?: {
search?: string;
isActive?: boolean | "all";
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}): Promise<PatientListResponse> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set("search", params.search);
if (params?.isActive !== undefined) {
searchParams.set("isActive", params.isActive === "all" ? "all" : String(params.isActive));
}
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
const query = searchParams.toString();
return patientFetch(`/patients${query ? `?${query}` : ""}`);
}
/**
* Get patient by ID with their cases
*/
export async function getPatient(patientId: number): Promise<{ patient: Patient; cases: PatientCase[] }> {
return patientFetch(`/patients/${patientId}`);
}
/**
* Update patient
*/
export async function updatePatient(
patientId: number,
data: Partial<PatientInput> & { isActive?: boolean }
): Promise<{ patient: Patient }> {
return patientFetch(`/patients/${patientId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
/**
* Archive patient (soft delete - preserves all data)
*/
export async function archivePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return patientFetch(`/patients/${patientId}/archive`, {
method: "POST",
});
}
/**
* Unarchive patient (restore)
*/
export async function unarchivePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return patientFetch(`/patients/${patientId}/unarchive`, {
method: "POST",
});
}
/**
* Delete patient - DEPRECATED: Use archivePatient instead
* This now archives instead of deleting
*/
export async function deletePatient(patientId: number, hard = false): Promise<{ message: string }> {
return patientFetch(`/patients/${patientId}${hard ? "?hard=true" : ""}`, {
method: "DELETE",
});
}
/**
* Create a case for a patient
*/
export async function createPatientCase(
patientId: number,
data?: { notes?: string; visitDate?: string }
): Promise<{ caseId: string; patientId: number; status: string }> {
return patientFetch(`/patients/${patientId}/cases`, {
method: "POST",
body: JSON.stringify(data || {}),
});
}
/**
* Get patient statistics
*/
export async function getPatientStats(): Promise<{ stats: PatientStats }> {
return patientFetch("/patients-stats");
}
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Format patient name
*/
export function formatPatientName(patient: Patient | { first_name: string; last_name: string }): string {
return `${patient.first_name} ${patient.last_name}`;
}
/**
* Calculate patient age from date of birth
*/
export function calculateAge(dateOfBirth: string | null): number | null {
if (!dateOfBirth) return null;
const today = new Date();
const birth = new Date(dateOfBirth);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
/**
* Format date for display
*/
export function formatDate(date: string | null): string {
if (!date) return "-";
return new Date(date).toLocaleDateString();
}

View File

@@ -34,6 +34,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [shouldFadeIn, setShouldFadeIn] = useState(false); const [shouldFadeIn, setShouldFadeIn] = useState(false);
const prevPathRef = useRef(location.pathname); const prevPathRef = useRef(location.pathname);
const isPatients = location.pathname === "/patients" || location.pathname.startsWith("/patients/");
const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/"); const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/");
const isEditShell = location.pathname.startsWith("/editor"); const isEditShell = location.pathname.startsWith("/editor");
const isAdmin = location.pathname.startsWith("/admin"); const isAdmin = location.pathname.startsWith("/admin");
@@ -72,6 +73,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
<nav className="bf-nav" aria-label="Primary navigation"> <nav className="bf-nav" aria-label="Primary navigation">
<NavItem label="Patients" active={isPatients} onClick={() => nav("/patients")} />
<NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} /> <NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} />
<NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} /> <NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} />
{userIsAdmin && ( {userIsAdmin && (

View File

@@ -49,6 +49,50 @@
font-family: monospace; font-family: monospace;
} }
.case-header-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.patient-name-header {
font-size: 20px;
font-weight: 700;
color: #fff;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.patient-mrn-badge {
font-size: 12px;
font-weight: 500;
color: #94a3b8;
background: rgba(148, 163, 184, 0.15);
padding: 4px 10px;
border-radius: 4px;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.case-meta-row {
display: flex;
align-items: center;
gap: 12px;
}
.case-id-label {
font-size: 13px;
font-weight: 500;
color: #94a3b8;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.visit-date-label {
font-size: 13px;
color: #64748b;
}
/* Status Badges */ /* Status Badges */
.status-badge { .status-badge {
padding: 4px 10px; padding: 4px 10px;

View File

@@ -22,7 +22,7 @@ type AuthContextType = {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_STORAGE_KEY = "braceflow_auth"; const AUTH_STORAGE_KEY = "braceflow_auth";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api"; const API_BASE = import.meta.env.VITE_API_BASE || "/api";
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { fetchCases, createCaseAndUploadXray, getDownloadUrl, deleteCase } from "../api/braceflowApi"; import { fetchCases, getDownloadUrl, archiveCase } from "../api/braceflowApi";
import type { CaseRecord } from "../api/braceflowApi"; import type { CaseRecord } from "../api/braceflowApi";
export default function Dashboard({ onView }: { onView?: (id: string) => void }) { export default function Dashboard({ onView }: { onView?: (id: string) => void }) {
@@ -10,18 +10,12 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
// Upload state
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Thumbnail URLs for each case // Thumbnail URLs for each case
const [thumbnails, setThumbnails] = useState<Record<string, string>>({}); const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
// Dropdown menu state // Dropdown menu state
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null); const [archiving, setArchiving] = useState<string | null>(null);
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -57,19 +51,19 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
setThumbnails(prev => ({ ...prev, ...newThumbnails })); setThumbnails(prev => ({ ...prev, ...newThumbnails }));
} }
// Handle delete case // Handle archive case (shown to user as "delete")
async function handleDelete(caseId: string, e: React.MouseEvent) { async function handleArchive(caseId: string, e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();
if (!confirm(`Are you sure you want to delete case "${caseId}"?\n\nThis will permanently remove the case and all associated files.`)) { if (!confirm(`Are you sure you want to delete case "${caseId}"?`)) {
return; return;
} }
setDeleting(caseId); setArchiving(caseId);
setOpenMenu(null); setOpenMenu(null);
try { try {
await deleteCase(caseId); await archiveCase(caseId);
setCases(prev => prev.filter(c => c.caseId !== caseId)); setCases(prev => prev.filter(c => c.caseId !== caseId));
setThumbnails(prev => { setThumbnails(prev => {
const updated = { ...prev }; const updated = { ...prev };
@@ -77,9 +71,9 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
return updated; return updated;
}); });
} catch (e: any) { } catch (e: any) {
setErr(e?.message || "Failed to delete case"); setErr(e?.message || "Failed to archive case");
} finally { } finally {
setDeleting(null); setArchiving(null);
} }
} }
@@ -120,59 +114,6 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
nav(`/cases/${encodeURIComponent(caseId)}/analysis`); nav(`/cases/${encodeURIComponent(caseId)}/analysis`);
} }
const handleFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
setErr("Please upload an image file (JPEG, PNG, etc.)");
return;
}
setUploading(true);
setErr(null);
setUploadProgress("Creating case...");
try {
setUploadProgress("Uploading X-ray...");
const { caseId } = await createCaseAndUploadXray(file);
setUploadProgress("Complete!");
// Refresh the case list and navigate to the new case
await load();
viewCase(caseId);
} catch (e: any) {
setErr(e?.message || "Upload failed");
} finally {
setUploading(false);
setUploadProgress("");
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
}, []);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const hasCases = cases.length > 0; const hasCases = cases.length > 0;
return ( return (
@@ -184,44 +125,12 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
</div> </div>
<div className="bf-spacer" /> <div className="bf-spacer" />
<div className="bf-toolbar"> <div className="bf-toolbar">
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading || uploading}> <button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading}>
{loading ? "Loading..." : "Refresh"} {loading ? "Loading..." : "Refresh"}
</button> </button>
</div> </div>
</div> </div>
{/* Upload Area */}
<div
className={`bf-upload-zone ${dragActive ? "bf-upload-zone--active" : ""} ${uploading ? "bf-upload-zone--uploading" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
style={{ display: "none" }}
disabled={uploading}
/>
{uploading ? (
<div className="bf-upload-content">
<div className="bf-upload-spinner"></div>
<p className="bf-upload-text">{uploadProgress}</p>
</div>
) : (
<div className="bf-upload-content">
<div className="bf-upload-icon">+</div>
<p className="bf-upload-text">
<strong>Click to upload</strong> or drag and drop an X-ray image
</p>
<p className="bf-upload-hint">JPEG, PNG, WebP supported</p>
</div>
)}
</div>
{err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>} {err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>}
{/* Cases List */} {/* Cases List */}
@@ -231,20 +140,20 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{loading ? ( {loading ? (
<div className="muted">Loading cases...</div> <div className="muted">Loading cases...</div>
) : !hasCases ? ( ) : !hasCases ? (
<div className="bf-empty">No cases yet. Upload an X-ray above to create your first case.</div> <div className="bf-empty">No cases yet. Go to <a href="/patients">Patients</a> to add a patient and create a case.</div>
) : ( ) : (
<div className="bf-cases-list"> <div className="bf-cases-list">
{cases.map((c) => { {cases.map((c) => {
const date = new Date(c.created_at); const date = new Date(c.created_at);
const isValidDate = !isNaN(date.getTime()); const isValidDate = !isNaN(date.getTime());
const thumbUrl = thumbnails[c.caseId]; const thumbUrl = thumbnails[c.caseId];
const isDeleting = deleting === c.caseId; const isArchiving = archiving === c.caseId;
return ( return (
<div <div
key={c.caseId} key={c.caseId}
className={`bf-case-row ${isDeleting ? "bf-case-row--deleting" : ""}`} className={`bf-case-row ${isArchiving ? "bf-case-row--archiving" : ""}`}
onClick={() => !isDeleting && viewCase(c.caseId)} onClick={() => !isArchiving && viewCase(c.caseId)}
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="bf-case-thumb"> <div className="bf-case-thumb">
@@ -257,6 +166,9 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{/* Case Info */} {/* Case Info */}
<div className="bf-case-row-info"> <div className="bf-case-row-info">
{c.patient && (
<span className="bf-case-row-patient">{c.patient.fullName}</span>
)}
<span className="bf-case-row-id">{c.caseId}</span> <span className="bf-case-row-id">{c.caseId}</span>
{isValidDate && ( {isValidDate && (
<span className="bf-case-row-date"> <span className="bf-case-row-date">
@@ -267,7 +179,7 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{/* Menu Button */} {/* Menu Button */}
<div className="bf-case-menu-container"> <div className="bf-case-menu-container">
{isDeleting ? ( {isArchiving ? (
<div className="bf-case-menu-spinner"></div> <div className="bf-case-menu-spinner"></div>
) : ( ) : (
<> <>
@@ -284,7 +196,7 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
<div className="bf-case-dropdown"> <div className="bf-case-dropdown">
<button <button
className="bf-case-dropdown-item bf-case-dropdown-item--danger" className="bf-case-dropdown-item bf-case-dropdown-item--danger"
onClick={(e) => handleDelete(c.caseId, e)} onClick={(e) => handleArchive(c.caseId, e)}
> >
Delete Case Delete Case
</button> </button>

View File

@@ -0,0 +1,338 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
getPatient,
createPatientCase,
type Patient,
type PatientCase,
formatPatientName,
calculateAge,
formatDate,
} from "../api/patientApi";
import { getDownloadUrl } from "../api/braceflowApi";
export default function PatientDetail() {
const navigate = useNavigate();
const { patientId } = useParams();
const [patient, setPatient] = useState<Patient | null>(null);
const [cases, setCases] = useState<PatientCase[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creatingCase, setCreatingCase] = useState(false);
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
useEffect(() => {
loadPatient();
}, [patientId]);
async function loadPatient() {
try {
const data = await getPatient(parseInt(patientId!));
setPatient(data.patient);
setCases(data.cases);
// Load thumbnails for cases
loadThumbnails(data.cases);
} catch (err: any) {
setError(err.message || "Failed to load patient");
} finally {
setLoading(false);
}
}
async function loadThumbnails(caseList: PatientCase[]) {
const newThumbnails: Record<string, string> = {};
await Promise.all(
caseList.map(async (c) => {
try {
const result = await getDownloadUrl(c.case_id, "xray");
newThumbnails[c.case_id] = result.url;
} catch {
// No thumbnail available
}
})
);
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
}
async function handleCreateCase() {
setCreatingCase(true);
setError(null);
try {
const result = await createPatientCase(parseInt(patientId!));
// Navigate to the new case
navigate(`/cases/${result.caseId}`);
} catch (err: any) {
setError(err.message || "Failed to create case");
setCreatingCase(false);
}
}
function getStatusColor(status: string): string {
switch (status) {
case "brace_generated":
return "bf-status-success";
case "analysis_complete":
case "landmarks_approved":
return "bf-status-info";
case "created":
case "landmarks_detected":
return "bf-status-warning";
case "failed":
case "brace_failed":
return "bf-status-error";
default:
return "bf-status-default";
}
}
function formatStatus(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
if (loading) {
return <div className="bf-loading">Loading patient...</div>;
}
if (!patient) {
return (
<div className="bf-error-state">
<h2>Patient not found</h2>
<button className="bf-btn bf-btn-primary" onClick={() => navigate("/patients")}>
Back to Patients
</button>
</div>
);
}
const age = calculateAge(patient.date_of_birth);
return (
<div className="bf-patient-detail-page">
{/* Header */}
<div className="bf-patient-detail-header">
<button className="bf-back-btn" onClick={() => navigate("/patients")}>
Back to Patients
</button>
<div className="bf-patient-detail-title">
<div className="bf-patient-avatar-large">
{patient.first_name[0]}
{patient.last_name[0]}
</div>
<div>
<h1>{formatPatientName(patient)}</h1>
<div className="bf-patient-meta">
{patient.mrn && <span className="bf-meta-item">MRN: {patient.mrn}</span>}
{age && <span className="bf-meta-item">{age} years old</span>}
{patient.gender && (
<span className="bf-meta-item bf-capitalize">{patient.gender}</span>
)}
{!patient.is_active && (
<span className="bf-badge bf-badge-inactive">Inactive</span>
)}
</div>
</div>
</div>
<div className="bf-patient-detail-actions">
<button
className="bf-btn bf-btn-outline"
onClick={() => navigate(`/patients/${patientId}/edit`)}
>
Edit Patient
</button>
</div>
</div>
{error && <div className="bf-error-message">{error}</div>}
<div className="bf-patient-detail-content">
{/* Patient Information */}
<div className="bf-patient-info-section">
<div className="bf-info-card">
<h3>Contact Information</h3>
<div className="bf-info-grid">
<div className="bf-info-item">
<span className="bf-info-label">Email</span>
<span className="bf-info-value">{patient.email || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Phone</span>
<span className="bf-info-value">{patient.phone || "-"}</span>
</div>
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Address</span>
<span className="bf-info-value">{patient.address || "-"}</span>
</div>
</div>
</div>
<div className="bf-info-card">
<h3>Medical Information</h3>
<div className="bf-info-grid">
<div className="bf-info-item">
<span className="bf-info-label">Diagnosis</span>
<span className="bf-info-value">{patient.diagnosis || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Curve Type</span>
<span className="bf-info-value">
{patient.curve_type ? (
<span className="bf-curve-badge">{patient.curve_type}</span>
) : (
"-"
)}
</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Referring Physician</span>
<span className="bf-info-value">{patient.referring_physician || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Insurance</span>
<span className="bf-info-value">{patient.insurance_info || "-"}</span>
</div>
{patient.medical_history && (
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Medical History</span>
<span className="bf-info-value bf-info-text">
{patient.medical_history}
</span>
</div>
)}
{patient.notes && (
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Notes</span>
<span className="bf-info-value bf-info-text">{patient.notes}</span>
</div>
)}
</div>
</div>
</div>
{/* Cases Section */}
<div className="bf-patient-cases-section">
<div className="bf-section-header">
<h2>Cases ({cases.length})</h2>
<button
className="bf-btn bf-btn-small bf-btn-primary"
onClick={handleCreateCase}
disabled={creatingCase}
>
+ New Case
</button>
</div>
{cases.length > 0 ? (
<div className="bf-cases-list">
{cases.map((caseItem) => {
// Parse analysis result for additional info
let rigoType = null;
let cobbAngles = null;
try {
if (caseItem.analysis_result) {
const result =
typeof caseItem.analysis_result === "string"
? JSON.parse(caseItem.analysis_result)
: caseItem.analysis_result;
rigoType = result.rigoType || result.rigo_classification?.type;
cobbAngles = result.cobbAngles || result.cobb_angles;
}
} catch (e) {
/* ignore */
}
const thumbUrl = thumbnails[caseItem.case_id];
return (
<div
key={caseItem.case_id}
className="bf-case-card bf-case-card-with-thumb"
onClick={() => navigate(`/cases/${caseItem.case_id}`)}
>
{/* X-ray Thumbnail */}
<div className="bf-case-card-thumb">
{thumbUrl ? (
<img src={thumbUrl} alt="X-ray" className="bf-case-thumb-img" />
) : (
<div className="bf-case-thumb-placeholder">
<span>X</span>
</div>
)}
</div>
<div className="bf-case-card-content">
<div className="bf-case-card-header">
<div>
<span className="bf-case-id">{caseItem.case_id}</span>
{caseItem.visit_date && (
<span className="bf-case-date">
Visit: {formatDate(caseItem.visit_date)}
</span>
)}
</div>
<span className={`bf-status-badge ${getStatusColor(caseItem.status)}`}>
{formatStatus(caseItem.status)}
</span>
</div>
<div className="bf-case-card-body">
{rigoType && (
<div className="bf-case-detail">
<span className="bf-detail-label">Rigo Type:</span>
<span className="bf-rigo-badge">{rigoType}</span>
</div>
)}
{cobbAngles && (
<div className="bf-case-detail">
<span className="bf-detail-label">Cobb Angles:</span>
<span className="bf-cobb-values">
{cobbAngles.PT !== undefined && (
<span>PT: {cobbAngles.PT.toFixed(1)}°</span>
)}
{cobbAngles.MT !== undefined && (
<span>MT: {cobbAngles.MT.toFixed(1)}°</span>
)}
{cobbAngles.TL !== undefined && (
<span>TL: {cobbAngles.TL.toFixed(1)}°</span>
)}
</span>
</div>
)}
{caseItem.body_scan_url && (
<div className="bf-case-detail">
<span className="bf-detail-icon">📷</span>
<span>Body scan uploaded</span>
</div>
)}
</div>
<div className="bf-case-card-footer">
<span className="bf-case-created">
Created: {formatDate(caseItem.created_at)}
</span>
<span className="bf-case-arrow"></span>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="bf-empty-cases">
<p>No cases yet for this patient</p>
<button
className="bf-btn bf-btn-primary"
onClick={handleCreateCase}
disabled={creatingCase}
>
Create First Case
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,336 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
createPatient,
getPatient,
updatePatient,
type PatientInput,
formatPatientName,
} from "../api/patientApi";
export default function PatientForm() {
const navigate = useNavigate();
const { patientId } = useParams();
const isEditing = !!patientId;
const [loading, setLoading] = useState(isEditing);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<PatientInput>({
firstName: "",
lastName: "",
mrn: "",
dateOfBirth: "",
gender: undefined,
email: "",
phone: "",
address: "",
diagnosis: "",
curveType: "",
medicalHistory: "",
referringPhysician: "",
insuranceInfo: "",
notes: "",
});
useEffect(() => {
if (isEditing) {
loadPatient();
}
}, [patientId]);
async function loadPatient() {
try {
const { patient } = await getPatient(parseInt(patientId!));
setFormData({
firstName: patient.first_name,
lastName: patient.last_name,
mrn: patient.mrn || "",
dateOfBirth: patient.date_of_birth || "",
gender: patient.gender || undefined,
email: patient.email || "",
phone: patient.phone || "",
address: patient.address || "",
diagnosis: patient.diagnosis || "",
curveType: patient.curve_type || "",
medicalHistory: patient.medical_history || "",
referringPhysician: patient.referring_physician || "",
insuranceInfo: patient.insurance_info || "",
notes: patient.notes || "",
});
} catch (err: any) {
setError(err.message || "Failed to load patient");
} finally {
setLoading(false);
}
}
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!formData.firstName.trim() || !formData.lastName.trim()) {
setError("First name and last name are required");
return;
}
setSaving(true);
try {
if (isEditing) {
await updatePatient(parseInt(patientId!), formData);
navigate(`/patients/${patientId}`);
} else {
const { patient } = await createPatient(formData);
navigate(`/patients/${patient.id}`);
}
} catch (err: any) {
setError(err.message || "Failed to save patient");
} finally {
setSaving(false);
}
}
if (loading) {
return <div className="bf-loading">Loading patient...</div>;
}
return (
<div className="bf-patient-form-page">
<div className="bf-form-header">
<button
className="bf-back-btn"
onClick={() => navigate(isEditing ? `/patients/${patientId}` : "/patients")}
>
Back
</button>
<h1>{isEditing ? "Edit Patient" : "Add New Patient"}</h1>
</div>
{error && <div className="bf-error-message">{error}</div>}
<form onSubmit={handleSubmit} className="bf-patient-form">
{/* Basic Information */}
<section className="bf-form-section">
<h2>Basic Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="firstName">First Name *</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
required
placeholder="Enter first name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="lastName">Last Name *</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
required
placeholder="Enter last name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="mrn">Medical Record Number (MRN)</label>
<input
type="text"
id="mrn"
name="mrn"
value={formData.mrn}
onChange={handleChange}
placeholder="Enter MRN (optional)"
/>
</div>
<div className="bf-form-group">
<label htmlFor="dateOfBirth">Date of Birth</label>
<input
type="date"
id="dateOfBirth"
name="dateOfBirth"
value={formData.dateOfBirth}
onChange={handleChange}
/>
</div>
<div className="bf-form-group">
<label htmlFor="gender">Gender</label>
<select
id="gender"
name="gender"
value={formData.gender || ""}
onChange={handleChange}
>
<option value="">Select gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
</section>
{/* Contact Information */}
<section className="bf-form-section">
<h2>Contact Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter email address"
/>
</div>
<div className="bf-form-group">
<label htmlFor="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="Enter phone number"
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="address">Address</label>
<input
type="text"
id="address"
name="address"
value={formData.address}
onChange={handleChange}
placeholder="Enter address"
/>
</div>
</div>
</section>
{/* Medical Information */}
<section className="bf-form-section">
<h2>Medical Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="diagnosis">Diagnosis</label>
<input
type="text"
id="diagnosis"
name="diagnosis"
value={formData.diagnosis}
onChange={handleChange}
placeholder="e.g., Idiopathic Scoliosis"
/>
</div>
<div className="bf-form-group">
<label htmlFor="curveType">Curve Type</label>
<select
id="curveType"
name="curveType"
value={formData.curveType || ""}
onChange={handleChange}
>
<option value="">Select curve type</option>
<option value="A1">A1 - 3 curves, main thoracic</option>
<option value="A2">A2 - 3 curves, thoracolumbar</option>
<option value="A3">A3 - 3 curves, false double major</option>
<option value="B1">B1 - 3 curves, combined</option>
<option value="B2">B2 - 3 curves, double major</option>
<option value="C1">C1 - 2 curves, main thoracic</option>
<option value="C2">C2 - 2 curves, main lumbar</option>
<option value="E1">E1 - Single curve, thoracic</option>
<option value="E2">E2 - Single curve, thoracolumbar</option>
</select>
</div>
<div className="bf-form-group">
<label htmlFor="referringPhysician">Referring Physician</label>
<input
type="text"
id="referringPhysician"
name="referringPhysician"
value={formData.referringPhysician}
onChange={handleChange}
placeholder="Enter physician name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="insuranceInfo">Insurance Information</label>
<input
type="text"
id="insuranceInfo"
name="insuranceInfo"
value={formData.insuranceInfo}
onChange={handleChange}
placeholder="Enter insurance details"
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="medicalHistory">Medical History</label>
<textarea
id="medicalHistory"
name="medicalHistory"
value={formData.medicalHistory}
onChange={handleChange}
rows={4}
placeholder="Enter relevant medical history..."
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows={3}
placeholder="Additional notes..."
/>
</div>
</div>
</section>
{/* Form Actions */}
<div className="bf-form-actions">
<button
type="button"
className="bf-btn bf-btn-outline"
onClick={() => navigate(isEditing ? `/patients/${patientId}` : "/patients")}
>
Cancel
</button>
<button type="submit" className="bf-btn bf-btn-primary" disabled={saving}>
{saving ? "Saving..." : isEditing ? "Update Patient" : "Create Patient"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
listPatients,
archivePatient,
type Patient,
formatPatientName,
calculateAge,
formatDate,
} from "../api/patientApi";
export default function PatientList() {
const navigate = useNavigate();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
// Filters
const [search, setSearch] = useState("");
const [limit] = useState(20);
const [offset, setOffset] = useState(0);
// Archive confirmation
const [archiveConfirm, setArchiveConfirm] = useState<Patient | null>(null);
useEffect(() => {
loadPatients();
}, [search, offset]);
async function loadPatients() {
setLoading(true);
setError(null);
try {
const data = await listPatients({
search: search || undefined,
isActive: true,
limit,
offset,
});
setPatients(data.patients);
setTotal(data.total);
} catch (err: any) {
setError(err.message || "Failed to load patients");
} finally {
setLoading(false);
}
}
async function handleArchive(patient: Patient) {
try {
await archivePatient(patient.id);
setArchiveConfirm(null);
loadPatients();
} catch (err: any) {
setError(err.message || "Failed to archive patient");
}
}
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
setSearch(e.target.value);
setOffset(0);
}
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
return (
<div className="bf-patients-page">
<div className="bf-patients-header">
<div>
<h1>Patients</h1>
<p className="bf-patients-subtitle">
Manage patient records and create new cases
</p>
</div>
<button
className="bf-btn bf-btn-primary bf-add-patient-btn"
onClick={() => navigate("/patients/new")}
>
<span className="bf-btn-icon">+</span>
Add Patient
</button>
</div>
{/* Filters */}
<div className="bf-patients-filters">
<div className="bf-search-box">
<input
type="text"
placeholder="Search by name, MRN, or email..."
value={search}
onChange={handleSearchChange}
className="bf-search-input"
/>
</div>
</div>
{error && <div className="bf-error-message">{error}</div>}
{loading ? (
<div className="bf-loading">Loading patients...</div>
) : patients.length > 0 ? (
<>
<div className="bf-patients-grid">
{patients.map((patient) => (
<div
key={patient.id}
className={`bf-patient-card ${!patient.is_active ? "inactive" : ""}`}
onClick={() => navigate(`/patients/${patient.id}`)}
>
<div className="bf-patient-card-header">
<div className="bf-patient-avatar">
{patient.first_name[0]}
{patient.last_name[0]}
</div>
<div className="bf-patient-info">
<h3 className="bf-patient-name">
{formatPatientName(patient)}
</h3>
{patient.mrn && (
<span className="bf-patient-mrn">MRN: {patient.mrn}</span>
)}
</div>
{!patient.is_active && (
<span className="bf-badge bf-badge-inactive">Inactive</span>
)}
</div>
<div className="bf-patient-card-body">
<div className="bf-patient-details">
{patient.date_of_birth && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Age:</span>
<span className="bf-detail-value">
{calculateAge(patient.date_of_birth)} years
</span>
</div>
)}
{patient.gender && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Gender:</span>
<span className="bf-detail-value bf-capitalize">
{patient.gender}
</span>
</div>
)}
{patient.diagnosis && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Diagnosis:</span>
<span className="bf-detail-value">{patient.diagnosis}</span>
</div>
)}
</div>
<div className="bf-patient-stats">
<div className="bf-patient-stat">
<span className="bf-stat-value">{patient.case_count || 0}</span>
<span className="bf-stat-label">Cases</span>
</div>
{patient.last_visit && (
<div className="bf-patient-stat">
<span className="bf-stat-value">{formatDate(patient.last_visit)}</span>
<span className="bf-stat-label">Last Visit</span>
</div>
)}
</div>
</div>
<div className="bf-patient-card-actions" onClick={(e) => e.stopPropagation()}>
<button
className="bf-btn bf-btn-small bf-btn-outline"
onClick={() => navigate(`/patients/${patient.id}/edit`)}
>
Edit
</button>
<button
className="bf-btn bf-btn-small bf-btn-outline bf-btn-danger"
onClick={() => setArchiveConfirm(patient)}
>
Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bf-pagination">
<span className="bf-pagination-info">
Showing {offset + 1} - {Math.min(offset + limit, total)} of {total} patients
</span>
<div className="bf-pagination-controls">
<button
className="bf-btn bf-btn-small"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</button>
<span className="bf-pagination-page">
Page {currentPage} of {totalPages}
</span>
<button
className="bf-btn bf-btn-small"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Next
</button>
</div>
</div>
)}
</>
) : (
<div className="bf-empty-state">
<div className="bf-empty-icon">👤</div>
<h3>No patients found</h3>
<p>
{search
? "Try adjusting your search criteria"
: "Get started by adding your first patient"}
</p>
{!search && (
<button
className="bf-btn bf-btn-primary"
onClick={() => navigate("/patients/new")}
>
Add First Patient
</button>
)}
</div>
)}
{/* Delete Confirmation Modal (archives behind the scenes) */}
{archiveConfirm && (
<div className="bf-modal-backdrop" onClick={() => setArchiveConfirm(null)}>
<div className="bf-modal" onClick={(e) => e.stopPropagation()}>
<h2>Delete Patient</h2>
<p>
Are you sure you want to delete{" "}
<strong>{formatPatientName(archiveConfirm)}</strong>?
</p>
<div className="bf-modal-actions">
<button
className="bf-btn bf-btn-outline"
onClick={() => setArchiveConfirm(null)}
>
Cancel
</button>
<button
className="bf-btn bf-btn-danger"
onClick={() => handleArchive(archiveConfirm)}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -422,14 +422,34 @@ export default function PipelineCaseDetail() {
{/* Header */} {/* Header */}
<header className="pipeline-header"> <header className="pipeline-header">
<div className="header-left"> <div className="header-left">
<button className="back-btn" onClick={() => nav('/')}> <button
className="back-btn"
onClick={() => caseData?.patient_id ? nav(`/patients/${caseData.patient_id}`) : nav('/patients')}
>
Back Back
</button> </button>
<h1 className="case-title">{caseId}</h1> <div className="case-header-info">
{caseData?.patient && (
<h1 className="patient-name-header">
{caseData.patient.fullName}
{caseData.patient.mrn && (
<span className="patient-mrn-badge">MRN: {caseData.patient.mrn}</span>
)}
</h1>
)}
<div className="case-meta-row">
<span className="case-id-label">{caseId}</span>
{caseData?.visit_date && (
<span className="visit-date-label">
Visit: {new Date(caseData.visit_date).toLocaleDateString()}
</span>
)}
<span className={`status-badge status-${caseData?.status || 'created'}`}> <span className={`status-badge status-${caseData?.status || 'created'}`}>
{caseData?.status?.replace(/_/g, ' ') || 'Created'} {caseData?.status?.replace(/_/g, ' ') || 'Created'}
</span> </span>
</div> </div>
</div>
</div>
</header> </header>
{/* Pipeline Steps Indicator */} {/* Pipeline Steps Indicator */}

View File

@@ -1,151 +1,675 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getAuditLog, type AuditLogEntry } from "../../api/adminApi"; import {
getApiActivity,
getApiActivityStats,
type ApiRequestEntry,
type ApiActivityStats,
type FileUploadInfo,
type ResponseSummary,
} from "../../api/adminApi";
export default function AdminActivity() { export default function AdminActivity() {
const [entries, setEntries] = useState<AuditLogEntry[]>([]); const [requests, setRequests] = useState<ApiRequestEntry[]>([]);
const [stats, setStats] = useState<ApiActivityStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
// Expanded row
const [expandedId, setExpandedId] = useState<number | null>(null);
// Filters // Filters
const [actionFilter, setActionFilter] = useState(""); const [methodFilter, setMethodFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [pathFilter, setPathFilter] = useState("");
const [usernameFilter, setUsernameFilter] = useState("");
const [limit, setLimit] = useState(50); const [limit, setLimit] = useState(50);
const [offset, setOffset] = useState(0);
// View mode
const [viewMode, setViewMode] = useState<"list" | "stats">("list");
useEffect(() => { useEffect(() => {
loadAuditLog(); loadActivity();
}, [actionFilter, limit]); }, [methodFilter, statusFilter, pathFilter, usernameFilter, limit, offset]);
async function loadAuditLog() { useEffect(() => {
if (viewMode === "stats") {
loadStats();
}
}, [viewMode]);
async function loadActivity() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await getAuditLog({ const data = await getApiActivity({
action: actionFilter || undefined, method: methodFilter || undefined,
statusCategory: (statusFilter as "2xx" | "3xx" | "4xx" | "5xx") || undefined,
path: pathFilter || undefined,
username: usernameFilter || undefined,
limit, limit,
offset,
}); });
setEntries(data.entries); setRequests(data.requests);
setTotal(data.total);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to load audit log"); setError(err.message || "Failed to load API activity");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
function formatAction(action: string): string { async function loadStats() {
return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); setStatsLoading(true);
}
function getActionColor(action: string): string {
if (action.includes("delete")) return "bf-action-danger";
if (action.includes("create")) return "bf-action-success";
if (action.includes("update")) return "bf-action-warning";
if (action.includes("login")) return "bf-action-info";
return "bf-action-default";
}
function parseDetails(details: string | null): Record<string, any> | null {
if (!details) return null;
try { try {
return JSON.parse(details); const data = await getApiActivityStats();
setStats(data.stats);
} catch (err: any) {
console.error("Failed to load stats:", err);
} finally {
setStatsLoading(false);
}
}
function getMethodColor(method: string): string {
switch (method.toUpperCase()) {
case "GET": return "bf-method-get";
case "POST": return "bf-method-post";
case "PUT": return "bf-method-put";
case "DELETE": return "bf-method-delete";
case "PATCH": return "bf-method-patch";
default: return "bf-method-default";
}
}
function getStatusColor(status: number | null): string {
if (!status) return "bf-status-unknown";
if (status >= 200 && status < 300) return "bf-status-success";
if (status >= 300 && status < 400) return "bf-status-redirect";
if (status >= 400 && status < 500) return "bf-status-client-error";
if (status >= 500) return "bf-status-server-error";
return "bf-status-unknown";
}
function formatResponseTime(ms: number | null): string {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function formatBytes(bytes: number | null): string {
if (!bytes) return "-";
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function parseJson<T>(str: string | null): T | null {
if (!str) return null;
try {
return JSON.parse(str) as T;
} catch { } catch {
return null; return null;
} }
} }
const uniqueActions = [...new Set(entries.map((e) => e.action))].sort(); function toggleExpand(id: number) {
setExpandedId(expandedId === id ? null : id);
}
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
// Render request parameters
function renderRequestParams(params: Record<string, any> | null) {
if (!params || Object.keys(params).length === 0) return null;
return (
<div className="bf-activity-params">
<h4>Request Parameters</h4>
<div className="bf-params-grid">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="bf-param-item">
<span className="bf-param-key">{key}</span>
<span className="bf-param-value">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
);
}
// Render file uploads
function renderFileUploads(files: FileUploadInfo[] | null) {
if (!files || files.length === 0) return null;
return (
<div className="bf-activity-files">
<h4>File Uploads</h4>
{files.map((file, idx) => (
<div key={idx} className="bf-file-item">
<span className="bf-file-icon">📁</span>
<div className="bf-file-details">
<span className="bf-file-name">{file.originalname}</span>
<span className="bf-file-meta">
{file.mimetype} {formatBytes(file.size)}
{file.filename && <> <code>{file.filename}</code></>}
</span>
</div>
</div>
))}
</div>
);
}
// Render response summary
function renderResponseSummary(summary: ResponseSummary | null, statusCode: number | null) {
if (!summary || Object.keys(summary).length === 0) return null;
return (
<div className="bf-activity-response">
<h4>Response Summary</h4>
<div className="bf-response-items">
{summary.success !== undefined && (
<div className={`bf-response-item ${summary.success ? 'bf-res-success' : 'bf-res-error'}`}>
<span className="bf-res-icon">{summary.success ? '✓' : '✗'}</span>
<span>{summary.success ? 'Success' : 'Failed'}</span>
</div>
)}
{summary.message && (
<div className="bf-response-item bf-res-message">
<span className="bf-res-label">Message:</span>
<span>{summary.message}</span>
</div>
)}
{summary.error && (
<div className="bf-response-item bf-res-error">
<span className="bf-res-label">Error:</span>
<span>{summary.error}</span>
</div>
)}
{summary.caseId && (
<div className="bf-response-item">
<span className="bf-res-label">Case ID:</span>
<code className="bf-res-code">{summary.caseId}</code>
</div>
)}
{summary.status && (
<div className="bf-response-item">
<span className="bf-res-label">Status:</span>
<span className="bf-res-badge">{summary.status}</span>
</div>
)}
{summary.tokenGenerated && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">🔑</span>
<span>Auth token generated</span>
</div>
)}
{summary.username && (
<div className="bf-response-item">
<span className="bf-res-label">User:</span>
<span>{summary.username}</span>
</div>
)}
{summary.rigoType && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-label">Rigo Type:</span>
<span className="bf-res-badge bf-rigo-badge">{summary.rigoType}</span>
</div>
)}
{summary.cobbAngles && (
<div className="bf-response-item">
<span className="bf-res-label">Cobb Angles:</span>
<span className="bf-cobb-angles">
{summary.cobbAngles.PT !== undefined && <span>PT: {summary.cobbAngles.PT.toFixed(1)}°</span>}
{summary.cobbAngles.MT !== undefined && <span>MT: {summary.cobbAngles.MT.toFixed(1)}°</span>}
{summary.cobbAngles.TL !== undefined && <span>TL: {summary.cobbAngles.TL.toFixed(1)}°</span>}
</span>
</div>
)}
{summary.vertebraeDetected && (
<div className="bf-response-item">
<span className="bf-res-label">Vertebrae:</span>
<span>{summary.vertebraeDetected} detected</span>
</div>
)}
{summary.landmarksCount && (
<div className="bf-response-item">
<span className="bf-res-label">Landmarks:</span>
<span>{summary.landmarksCount}</span>
</div>
)}
{summary.bracesGenerated && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-icon">🦴</span>
<span>Braces: </span>
{summary.bracesGenerated.regular && <span className="bf-brace-tag regular">Regular</span>}
{summary.bracesGenerated.vase && <span className="bf-brace-tag vase">Vase</span>}
</div>
)}
{summary.braceGenerated && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-icon">🦴</span>
<span>Brace generated</span>
{summary.braceVertices && <span className="bf-res-small">({summary.braceVertices.toLocaleString()} vertices)</span>}
</div>
)}
{summary.filesGenerated && summary.filesGenerated.length > 0 && (
<div className="bf-response-item">
<span className="bf-res-label">Files:</span>
<span className="bf-file-tags">
{summary.filesGenerated.map(f => (
<span key={f} className="bf-file-tag">{f}</span>
))}
</span>
</div>
)}
{summary.outputUrl && (
<div className="bf-response-item">
<span className="bf-res-label">Output:</span>
<code className="bf-res-code">{summary.outputUrl}</code>
</div>
)}
{summary.bodyScanUploaded && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">📷</span>
<span>Body scan uploaded</span>
</div>
)}
{summary.measurementsExtracted && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">📏</span>
<span>Measurements extracted</span>
</div>
)}
{summary.casesCount !== undefined && (
<div className="bf-response-item">
<span className="bf-res-label">Cases:</span>
<span>{summary.casesCount} returned</span>
</div>
)}
{summary.usersCount !== undefined && (
<div className="bf-response-item">
<span className="bf-res-label">Users:</span>
<span>{summary.usersCount} returned</span>
</div>
)}
{summary.total !== undefined && !summary.casesCount && !summary.usersCount && (
<div className="bf-response-item">
<span className="bf-res-label">Total:</span>
<span>{summary.total}</span>
</div>
)}
</div>
</div>
);
}
return ( return (
<div className="bf-admin-page"> <div className="bf-admin-page">
<div className="bf-admin-header"> <div className="bf-admin-header">
<div> <div>
<h1>Activity Log</h1> <h1>API Activity</h1>
<p className="bf-admin-subtitle">Audit trail of system actions</p> <p className="bf-admin-subtitle">
Track all HTTP API requests made by users
</p>
</div> </div>
<button className="btn" onClick={loadAuditLog} disabled={loading}> <div className="bf-admin-header-actions">
<div className="bf-admin-view-toggle">
<button
className={`bf-toggle-btn ${viewMode === "list" ? "active" : ""}`}
onClick={() => setViewMode("list")}
>
Request Log
</button>
<button
className={`bf-toggle-btn ${viewMode === "stats" ? "active" : ""}`}
onClick={() => setViewMode("stats")}
>
Statistics
</button>
</div>
<button className="btn" onClick={loadActivity} disabled={loading}>
Refresh Refresh
</button> </button>
</div> </div>
</div>
{viewMode === "stats" ? (
// Statistics View
<div className="bf-admin-stats-section">
{statsLoading ? (
<div className="bf-admin-loading">Loading statistics...</div>
) : stats ? (
<>
{/* Summary Cards */}
<div className="bf-admin-stats-grid">
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.total.toLocaleString()}</div>
<div className="bf-admin-stat-label">Total Requests</div>
</div>
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.responseTime.avg}ms</div>
<div className="bf-admin-stat-label">Avg Response Time</div>
</div>
<div className="bf-admin-stat-card bf-stat-warning">
<div className="bf-admin-stat-value">{stats.errorRate}%</div>
<div className="bf-admin-stat-label">Error Rate</div>
</div>
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.topUsers.length}</div>
<div className="bf-admin-stat-label">Active Users</div>
</div>
</div>
{/* Method Distribution */}
<div className="bf-admin-card">
<h3>Requests by Method</h3>
<div className="bf-admin-method-distribution">
{Object.entries(stats.byMethod).map(([method, count]) => (
<div key={method} className="bf-method-bar-container">
<span className={`bf-method-label ${getMethodColor(method)}`}>
{method}
</span>
<div className="bf-method-bar-wrapper">
<div
className={`bf-method-bar ${getMethodColor(method)}`}
style={{
width: `${(count / stats.total) * 100}%`,
}}
/>
</div>
<span className="bf-method-count">{count.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Status Distribution */}
<div className="bf-admin-card">
<h3>Requests by Status</h3>
<div className="bf-admin-status-distribution">
{Object.entries(stats.byStatusCategory).map(([category, count]) => (
<div key={category} className="bf-status-item">
<span className={`bf-status-label ${
category.includes("2xx") ? "bf-status-success" :
category.includes("4xx") ? "bf-status-client-error" :
category.includes("5xx") ? "bf-status-server-error" :
"bf-status-redirect"
}`}>
{category}
</span>
<span className="bf-status-count">{count.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Top Endpoints */}
<div className="bf-admin-card">
<h3>Top Endpoints</h3>
<table className="bf-admin-table">
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Requests</th>
<th>Avg Time</th>
</tr>
</thead>
<tbody>
{stats.topEndpoints.slice(0, 10).map((endpoint, idx) => (
<tr key={idx}>
<td>
<span className={`bf-method-badge ${getMethodColor(endpoint.method)}`}>
{endpoint.method}
</span>
</td>
<td className="bf-endpoint-path">{endpoint.path}</td>
<td>{endpoint.count.toLocaleString()}</td>
<td>{Math.round(endpoint.avg_response_time || 0)}ms</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Users */}
<div className="bf-admin-card">
<h3>Most Active Users</h3>
<table className="bf-admin-table">
<thead>
<tr>
<th>User</th>
<th>Requests</th>
</tr>
</thead>
<tbody>
{stats.topUsers.map((user, idx) => (
<tr key={idx}>
<td>{user.username || "Anonymous"}</td>
<td>{user.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div className="bf-admin-empty-state">
<p>No statistics available</p>
</div>
)}
</div>
) : (
// List View
<>
{/* Filters */} {/* Filters */}
<div className="bf-admin-filters"> <div className="bf-admin-filters">
<select <select
value={actionFilter} value={methodFilter}
onChange={(e) => setActionFilter(e.target.value)} onChange={(e) => { setMethodFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-select" className="bf-admin-filter-select"
> >
<option value="">All Actions</option> <option value="">All Methods</option>
{uniqueActions.map((action) => ( <option value="GET">GET</option>
<option key={action} value={action}>{formatAction(action)}</option> <option value="POST">POST</option>
))} <option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select> </select>
<select <select
value={limit} value={statusFilter}
onChange={(e) => setLimit(parseInt(e.target.value))} onChange={(e) => { setStatusFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-select" className="bf-admin-filter-select"
> >
<option value="25">Last 25</option> <option value="">All Status</option>
<option value="50">Last 50</option> <option value="2xx">2xx Success</option>
<option value="100">Last 100</option> <option value="3xx">3xx Redirect</option>
<option value="200">Last 200</option> <option value="4xx">4xx Client Error</option>
<option value="5xx">5xx Server Error</option>
</select>
<input
type="text"
placeholder="Filter by path..."
value={pathFilter}
onChange={(e) => { setPathFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-input"
/>
<input
type="text"
placeholder="Filter by user..."
value={usernameFilter}
onChange={(e) => { setUsernameFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-input"
/>
<select
value={limit}
onChange={(e) => { setLimit(parseInt(e.target.value)); setOffset(0); }}
className="bf-admin-filter-select"
>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select> </select>
</div> </div>
{error && <div className="bf-admin-error">{error}</div>} {error && <div className="bf-admin-error">{error}</div>}
{loading ? ( {loading ? (
<div className="bf-admin-loading">Loading activity log...</div> <div className="bf-admin-loading">Loading API activity...</div>
) : entries.length > 0 ? ( ) : requests.length > 0 ? (
<>
<div className="bf-admin-card"> <div className="bf-admin-card">
<div className="bf-admin-activity-list"> <div className="bf-activity-list">
{entries.map((entry) => { {requests.map((req) => {
const details = parseDetails(entry.details); const queryParams = parseJson<Record<string, any>>(req.query_params);
const requestParams = parseJson<Record<string, any>>(req.request_params);
const fileUploads = parseJson<FileUploadInfo[]>(req.file_uploads);
const responseSummary = parseJson<ResponseSummary>(req.response_summary);
const isExpanded = expandedId === req.id;
const hasDetails = requestParams || fileUploads || responseSummary || queryParams;
return ( return (
<div key={entry.id} className="bf-admin-activity-item"> <div
<div className="bf-admin-activity-icon"> key={req.id}
<span className={`bf-admin-activity-dot ${getActionColor(entry.action)}`} /> className={`bf-activity-row ${isExpanded ? 'expanded' : ''} ${hasDetails ? 'has-details' : ''}`}
</div> >
<div className="bf-admin-activity-content"> <div
<div className="bf-admin-activity-header"> className="bf-activity-main"
<span className={`bf-admin-activity-action ${getActionColor(entry.action)}`}> onClick={() => hasDetails && toggleExpand(req.id)}
{formatAction(entry.action)} >
<div className="bf-activity-left">
<span className={`bf-method-badge ${getMethodColor(req.method)}`}>
{req.method}
</span> </span>
<span className="bf-admin-activity-entity"> <span className="bf-activity-path">
{entry.entity_type} {req.path}
{entry.entity_id && `: ${entry.entity_id}`} {queryParams && Object.keys(queryParams).length > 0 && (
<span className="bf-query-indicator" title={JSON.stringify(queryParams, null, 2)}>
?{Object.keys(queryParams).length}
</span>
)}
</span> </span>
</div> </div>
<div className="bf-admin-activity-meta"> <div className="bf-activity-center">
<span className="bf-admin-activity-user"> <span className="bf-activity-user">
{entry.username || "System"} {req.username || <span className="bf-anonymous">anonymous</span>}
</span> </span>
<span className="bf-admin-activity-time"> <span className={`bf-status-badge ${getStatusColor(req.status_code)}`}>
{new Date(entry.created_at).toLocaleString()} {req.status_code || "-"}
</span>
<span className="bf-activity-time-value">
{formatResponseTime(req.response_time_ms)}
</span>
</div>
<div className="bf-activity-right">
<span className="bf-activity-timestamp">
{new Date(req.created_at).toLocaleString()}
</span>
{hasDetails && (
<span className={`bf-expand-icon ${isExpanded ? 'expanded' : ''}`}>
</span> </span>
{entry.ip_address && (
<span className="bf-admin-activity-ip">IP: {entry.ip_address}</span>
)} )}
</div> </div>
{details && Object.keys(details).length > 0 && ( </div>
<div className="bf-admin-activity-details">
{Object.entries(details).map(([key, value]) => ( {isExpanded && hasDetails && (
<span key={key} className="bf-admin-activity-detail"> <div className="bf-activity-details">
<strong>{key}:</strong> {String(value)} {queryParams && Object.keys(queryParams).length > 0 && (
</span> <div className="bf-activity-params">
<h4>Query Parameters</h4>
<div className="bf-params-grid">
{Object.entries(queryParams).map(([key, value]) => (
<div key={key} className="bf-param-item">
<span className="bf-param-key">{key}</span>
<span className="bf-param-value">{String(value)}</span>
</div>
))} ))}
</div> </div>
)}
</div> </div>
)}
{renderRequestParams(requestParams)}
{renderFileUploads(fileUploads)}
{renderResponseSummary(responseSummary, req.status_code)}
<div className="bf-activity-meta-details">
<span>IP: {req.ip_address || 'unknown'}</span>
<span>Request: {formatBytes(req.request_body_size)}</span>
<span>Response: {formatBytes(req.response_body_size)}</span>
</div>
</div>
)}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
{/* Pagination */}
<div className="bf-admin-pagination">
<span className="bf-pagination-info">
Showing {offset + 1} - {Math.min(offset + limit, total)} of {total.toLocaleString()} requests
</span>
<div className="bf-pagination-controls">
<button
className="bf-pagination-btn"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</button>
<span className="bf-pagination-page">
Page {currentPage} of {totalPages}
</span>
<button
className="bf-pagination-btn"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Next
</button>
</div>
</div>
</>
) : ( ) : (
<div className="bf-admin-empty-state"> <div className="bf-admin-empty-state">
<p>No activity recorded yet</p> <p>No API activity recorded yet</p>
<p className="bf-admin-empty-hint">
API requests will appear here as users interact with the system
</p>
</div> </div>
)} )}
</>
)}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { listCasesAdmin, type AdminCase, type ListCasesResponse } from "../../api/adminApi"; import { listCasesAdmin, restoreCase, type AdminCase, type ListCasesResponse } from "../../api/adminApi";
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: "", label: "All Statuses" }, { value: "", label: "All Statuses" },
@@ -13,6 +13,12 @@ const STATUS_OPTIONS = [
{ value: "brace_failed", label: "Brace Failed" }, { value: "brace_failed", label: "Brace Failed" },
]; ];
const ARCHIVE_OPTIONS = [
{ value: "active", label: "Active Cases" },
{ value: "all", label: "All Cases" },
{ value: "archived", label: "Deleted Only" },
];
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
export default function AdminCases() { export default function AdminCases() {
@@ -21,9 +27,11 @@ export default function AdminCases() {
const [data, setData] = useState<ListCasesResponse | null>(null); const [data, setData] = useState<ListCasesResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [restoring, setRestoring] = useState<string | null>(null);
// Filters // Filters
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [archiveFilter, setArchiveFilter] = useState("active");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("created_at"); const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC"); const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
@@ -40,6 +48,8 @@ export default function AdminCases() {
sortOrder, sortOrder,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: currentPage * PAGE_SIZE, offset: currentPage * PAGE_SIZE,
includeArchived: archiveFilter === "all",
archivedOnly: archiveFilter === "archived",
}); });
setData(result); setData(result);
} catch (err: any) { } catch (err: any) {
@@ -47,7 +57,19 @@ export default function AdminCases() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [statusFilter, searchQuery, sortBy, sortOrder, currentPage]); }, [statusFilter, archiveFilter, searchQuery, sortBy, sortOrder, currentPage]);
async function handleRestore(caseId: string) {
setRestoring(caseId);
try {
await restoreCase(caseId);
loadCases();
} catch (err: any) {
setError(err.message || "Failed to restore case");
} finally {
setRestoring(null);
}
}
useEffect(() => { useEffect(() => {
loadCases(); loadCases();
@@ -156,6 +178,19 @@ export default function AdminCases() {
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
</select> </select>
<select
value={archiveFilter}
onChange={(e) => {
setArchiveFilter(e.target.value);
setCurrentPage(0);
}}
className="bf-admin-filter-select"
>
{ARCHIVE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div> </div>
{error && <div className="bf-admin-error">{error}</div>} {error && <div className="bf-admin-error">{error}</div>}
@@ -206,6 +241,9 @@ export default function AdminCases() {
<span className={`bf-admin-case-status ${getStatusColor(c.status)}`}> <span className={`bf-admin-case-status ${getStatusColor(c.status)}`}>
{c.status.replace(/_/g, " ")} {c.status.replace(/_/g, " ")}
</span> </span>
{c.is_archived && (
<span className="bf-admin-archived-badge">Deleted</span>
)}
</td> </td>
<td> <td>
{rigoType ? ( {rigoType ? (
@@ -235,13 +273,22 @@ export default function AdminCases() {
{new Date(c.created_at).toLocaleTimeString()} {new Date(c.created_at).toLocaleTimeString()}
</span> </span>
</td> </td>
<td> <td className="bf-admin-actions-cell">
<button <button
className="bf-admin-action-btn" className="bf-admin-action-btn"
onClick={() => navigate(`/cases/${c.caseId}`)} onClick={() => navigate(`/cases/${c.caseId}`)}
> >
View View
</button> </button>
{c.is_archived && (
<button
className="bf-admin-action-btn bf-admin-restore-btn"
onClick={() => handleRestore(c.caseId)}
disabled={restoring === c.caseId}
>
{restoring === c.caseId ? "..." : "Restore"}
</button>
)}
</td> </td>
</tr> </tr>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -4,4 +4,18 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
// Proxy API requests to Docker nginx (which proxies to API container)
'/api': {
target: 'http://localhost:80',
changeOrigin: true,
},
// Proxy file requests
'/files': {
target: 'http://localhost:80',
changeOrigin: true,
},
},
},
}) })

View File

@@ -0,0 +1,68 @@
# ============================================
# BraceIQMed - Deploy to EC2 Server (Windows)
# Pushes to Gitea and updates server
# ============================================
param(
[string]$Message = "Update deployment"
)
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " BraceIQMed - Deploy to Server" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Configuration
$EC2_IP = "3.142.142.30"
$SSH_KEY = "C:\Users\msagh\OneDrive\Documents\GitHub\brace-final-key-2026.pem"
$REMOTE_DIR = "/home/ubuntu/DEPLOYMENTS/DEPLOYMENT_1"
# Change to project directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectDir = Split-Path -Parent $scriptDir
Set-Location $projectDir
Write-Host "[1/4] Checking for changes..." -ForegroundColor Yellow
$status = git status --porcelain
if ($status) {
Write-Host " Found uncommitted changes" -ForegroundColor Yellow
Write-Host ""
Write-Host "[2/4] Committing changes..." -ForegroundColor Yellow
git add .
git commit -m "$Message"
} else {
Write-Host " No local changes to commit" -ForegroundColor Green
}
Write-Host ""
Write-Host "[3/4] Pushing to Gitea..." -ForegroundColor Yellow
git push gitea main 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " Note: Push to Gitea failed or not configured" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "[4/4] Updating server..." -ForegroundColor Yellow
# SSH commands to update server
$sshCommands = @"
cd $REMOTE_DIR
echo 'Pulling latest changes...'
git pull origin main 2>/dev/null || echo 'Git pull skipped'
echo 'Rebuilding containers...'
docker compose build
echo 'Restarting containers...'
docker compose up -d
echo 'Checking status...'
sleep 5
docker compose ps
"@
ssh -i $SSH_KEY -o StrictHostKeyChecking=no ubuntu@$EC2_IP $sshCommands
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " Deployment complete!" -ForegroundColor Green
Write-Host " Server: https://braceiqmed.com" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# ============================================
# BraceIQMed - Deploy to EC2 Server (Linux/Mac)
# Pushes to Gitea and updates server
# ============================================
set -e
# Configuration
EC2_IP="3.142.142.30"
SSH_KEY="$HOME/.ssh/brace-final-key-2026.pem"
REMOTE_DIR="/home/ubuntu/DEPLOYMENTS/DEPLOYMENT_1"
MESSAGE="${1:-Update deployment}"
echo "========================================"
echo " BraceIQMed - Deploy to Server"
echo "========================================"
echo ""
# Change to project directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "[1/4] Checking for changes..."
if [[ -n $(git status --porcelain) ]]; then
echo " Found uncommitted changes"
echo ""
echo "[2/4] Committing changes..."
git add .
git commit -m "$MESSAGE"
else
echo " No local changes to commit"
fi
echo ""
echo "[3/4] Pushing to Gitea..."
git push gitea main 2>&1 || echo " Note: Push to Gitea failed or not configured"
echo ""
echo "[4/4] Updating server..."
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ubuntu@$EC2_IP << 'EOF'
cd /home/ubuntu/DEPLOYMENTS/DEPLOYMENT_1
echo 'Pulling latest changes...'
git pull origin main 2>/dev/null || echo 'Git pull skipped'
echo 'Rebuilding containers...'
docker compose build
echo 'Restarting containers...'
docker compose up -d
echo 'Checking status...'
sleep 5
docker compose ps
EOF
echo ""
echo "========================================"
echo " Deployment complete!"
echo " Server: https://braceiqmed.com"
echo "========================================"

60
scripts/update-local.ps1 Normal file
View File

@@ -0,0 +1,60 @@
# ============================================
# BraceIQMed - Local Update Script (Windows)
# Rebuilds and restarts Docker containers
# ============================================
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " BraceIQMed - Local Update" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Change to project directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectDir = Split-Path -Parent $scriptDir
Set-Location $projectDir
Write-Host "[1/3] Building Docker images..." -ForegroundColor Yellow
docker compose build
if ($LASTEXITCODE -ne 0) {
Write-Host "Build failed!" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "[2/3] Restarting containers..." -ForegroundColor Yellow
docker compose up -d
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to start containers!" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "[3/3] Waiting for health checks..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
# Check health
$health = docker compose ps --format json | ConvertFrom-Json
$allHealthy = $true
foreach ($container in $health) {
$status = $container.Health
$name = $container.Name
if ($status -eq "healthy") {
Write-Host "$name - healthy" -ForegroundColor Green
} elseif ($status -eq "starting") {
Write-Host "$name - starting..." -ForegroundColor Yellow
} else {
Write-Host "$name - $status" -ForegroundColor Red
$allHealthy = $false
}
}
Write-Host ""
if ($allHealthy) {
Write-Host "========================================" -ForegroundColor Green
Write-Host " Update complete!" -ForegroundColor Green
Write-Host " App running at: http://localhost" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
} else {
Write-Host "Some containers may still be starting..." -ForegroundColor Yellow
Write-Host "Check status with: docker compose ps" -ForegroundColor Yellow
}

38
scripts/update-local.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# ============================================
# BraceIQMed - Local Update Script (Linux/Mac)
# Rebuilds and restarts Docker containers
# ============================================
set -e
echo "========================================"
echo " BraceIQMed - Local Update"
echo "========================================"
echo ""
# Change to project directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "[1/3] Building Docker images..."
docker compose build
echo ""
echo "[2/3] Restarting containers..."
docker compose up -d
echo ""
echo "[3/3] Waiting for health checks..."
sleep 5
# Check health
echo ""
docker compose ps
echo ""
echo "========================================"
echo " Update complete!"
echo " App running at: http://localhost"
echo "========================================"