Add patient management, deployment scripts, and Docker fixes
This commit is contained in:
7
api/.dockerignore
Normal file
7
api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
@@ -12,10 +12,11 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (rebuild native modules for Linux)
|
||||
RUN npm ci --only=production && \
|
||||
npm rebuild better-sqlite3
|
||||
|
||||
# Copy application code
|
||||
# Copy application code (excluding node_modules via .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Create data directories
|
||||
|
||||
699
api/db/sqlite.js
699
api/db/sqlite.js
@@ -18,10 +18,36 @@ db.pragma('journal_mode = WAL');
|
||||
|
||||
// Create tables
|
||||
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 (
|
||||
case_id TEXT PRIMARY KEY,
|
||||
patient_id INTEGER,
|
||||
case_type TEXT NOT NULL DEFAULT 'braceflow',
|
||||
visit_date TEXT DEFAULT (date('now')),
|
||||
status TEXT NOT NULL DEFAULT 'created' CHECK(status IN (
|
||||
'created', 'running', 'completed', 'failed', 'cancelled',
|
||||
'processing_brace', 'brace_generated', 'brace_failed',
|
||||
@@ -38,9 +64,12 @@ db.exec(`
|
||||
body_scan_path TEXT DEFAULT NULL,
|
||||
body_scan_url 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_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
|
||||
@@ -109,7 +138,35 @@ db.exec(`
|
||||
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 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_created ON brace_cases(created_at);
|
||||
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_action ON audit_log(action);
|
||||
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
|
||||
@@ -143,6 +205,42 @@ try {
|
||||
db.exec(`ALTER TABLE brace_cases ADD COLUMN created_by INTEGER DEFAULT NULL`);
|
||||
} 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)
|
||||
// Note: In production, use proper bcrypt hashing. This is a simple hash for dev.
|
||||
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(`
|
||||
INSERT INTO brace_cases (case_id, case_type, status, notes, created_at, updated_at)
|
||||
VALUES (?, ?, 'created', ?, datetime('now'), datetime('now'))
|
||||
INSERT INTO brace_cases (case_id, patient_id, case_type, visit_date, status, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'created', ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
|
||||
const insertStep = db.prepare(`
|
||||
@@ -182,40 +280,108 @@ export function createCase(caseId, caseType = 'braceflow', notes = null) {
|
||||
`);
|
||||
|
||||
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) => {
|
||||
insertStep.run(caseId, stepName, idx + 1);
|
||||
});
|
||||
});
|
||||
|
||||
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(`
|
||||
SELECT case_id as caseId, case_type, status, current_step, notes,
|
||||
analysis_result, landmarks_data, created_at, updated_at
|
||||
FROM brace_cases
|
||||
ORDER BY created_at DESC
|
||||
SELECT c.case_id as caseId, c.patient_id, c.case_type, c.visit_date, c.status, c.current_step, c.notes,
|
||||
c.analysis_result, c.landmarks_data, c.is_archived, c.archived_at, c.created_at, c.updated_at,
|
||||
p.first_name as patient_first_name, p.last_name as patient_last_name,
|
||||
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) {
|
||||
const caseStmt = db.prepare(`
|
||||
SELECT case_id, case_type, status, current_step, notes,
|
||||
analysis_result, landmarks_data, analysis_data, markers_data,
|
||||
body_scan_path, body_scan_url, body_scan_metadata,
|
||||
created_at, updated_at
|
||||
FROM brace_cases
|
||||
WHERE case_id = ?
|
||||
SELECT c.case_id, c.patient_id, c.case_type, c.visit_date, c.status, c.current_step, c.notes,
|
||||
c.analysis_result, c.landmarks_data, c.analysis_data, c.markers_data,
|
||||
c.body_scan_path, c.body_scan_url, c.body_scan_metadata,
|
||||
c.created_at, c.updated_at,
|
||||
p.first_name as patient_first_name, p.last_name as patient_last_name,
|
||||
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(`
|
||||
@@ -267,9 +433,23 @@ export function getCase(caseId) {
|
||||
}
|
||||
} 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 {
|
||||
caseId: caseData.case_id,
|
||||
patient_id: caseData.patient_id,
|
||||
patient,
|
||||
case_type: caseData.case_type,
|
||||
visit_date: caseData.visit_date,
|
||||
status: caseData.status,
|
||||
current_step: caseData.current_step,
|
||||
notes: caseData.notes,
|
||||
@@ -458,6 +638,244 @@ export function updateStepStatus(caseId, stepName, status, errorMessage = null)
|
||||
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
|
||||
// ============================================
|
||||
@@ -631,6 +1049,210 @@ export function getAuditLog(options = {}) {
|
||||
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
|
||||
// ============================================
|
||||
@@ -819,7 +1441,7 @@ export function getUserStats() {
|
||||
* List cases with filters (for admin)
|
||||
*/
|
||||
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 values = [];
|
||||
@@ -828,6 +1450,13 @@ export function listCasesFiltered(options = {}) {
|
||||
if (createdBy) { where.push('c.created_by = ?'); values.push(createdBy); }
|
||||
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 validSortColumns = ['created_at', 'updated_at', 'status', 'case_id'];
|
||||
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
@@ -839,6 +1468,7 @@ export function listCasesFiltered(options = {}) {
|
||||
const stmt = db.prepare(`
|
||||
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.is_archived, c.archived_at,
|
||||
c.created_by, c.created_at, c.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM brace_cases c
|
||||
@@ -848,7 +1478,10 @@ export function listCasesFiltered(options = {}) {
|
||||
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 {
|
||||
cases,
|
||||
@@ -859,6 +1492,7 @@ export function listCasesFiltered(options = {}) {
|
||||
}
|
||||
|
||||
export default {
|
||||
// Case management
|
||||
createCase,
|
||||
listCases,
|
||||
listCasesFiltered,
|
||||
@@ -874,8 +1508,20 @@ export default {
|
||||
saveBodyScan,
|
||||
clearBodyScan,
|
||||
deleteCase,
|
||||
archiveCase,
|
||||
unarchiveCase,
|
||||
updateStepStatus,
|
||||
STEP_NAMES,
|
||||
// Patient management
|
||||
createPatient,
|
||||
getPatient,
|
||||
listPatients,
|
||||
updatePatient,
|
||||
deletePatient,
|
||||
archivePatient,
|
||||
unarchivePatient,
|
||||
getPatientCases,
|
||||
getPatientStats,
|
||||
// User management
|
||||
getUserByUsername,
|
||||
getUserById,
|
||||
@@ -892,6 +1538,11 @@ export default {
|
||||
// Audit logging
|
||||
logAudit,
|
||||
getAuditLog,
|
||||
// API request logging
|
||||
logApiRequest,
|
||||
getApiRequests,
|
||||
getApiRequestStats,
|
||||
cleanupOldApiRequests,
|
||||
// Analytics
|
||||
getCaseStats,
|
||||
getRigoDistribution,
|
||||
|
||||
690
api/server.js
690
api/server.js
@@ -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
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
@@ -155,10 +388,21 @@ app.post('/api/cases', (req, res) => {
|
||||
/**
|
||||
* List all cases
|
||||
* GET /api/cases
|
||||
* Query params:
|
||||
* - includeArchived: boolean - Include archived cases (admin only)
|
||||
* - archivedOnly: boolean - Show only archived cases (admin only)
|
||||
*/
|
||||
app.get('/api/cases', (req, res) => {
|
||||
try {
|
||||
const 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);
|
||||
} catch (err) {
|
||||
console.error('List cases error:', err);
|
||||
@@ -1497,28 +1741,79 @@ app.post('/api/cases/:caseId/skip-body-scan', (req, res) => {
|
||||
// ==============================================
|
||||
|
||||
/**
|
||||
* Delete case
|
||||
* DELETE /api/cases/:caseId
|
||||
* Archive case (soft delete - keeps all files)
|
||||
* POST /api/cases/:caseId/archive
|
||||
*/
|
||||
app.delete('/api/cases/:caseId', (req, res) => {
|
||||
app.post('/api/cases/:caseId/archive', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { caseId } = req.params;
|
||||
|
||||
// Delete from database
|
||||
db.deleteCase(caseId);
|
||||
|
||||
// Delete files
|
||||
const uploadDir = path.join(UPLOADS_DIR, caseId);
|
||||
const outputDir = path.join(OUTPUTS_DIR, caseId);
|
||||
|
||||
if (fs.existsSync(uploadDir)) {
|
||||
fs.rmSync(uploadDir, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(outputDir)) {
|
||||
fs.rmSync(outputDir, { recursive: true });
|
||||
const caseData = db.getCase(caseId);
|
||||
if (!caseData) {
|
||||
return res.status(404).json({ message: 'Case not found' });
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Delete case error:', err);
|
||||
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
|
||||
// ============================================
|
||||
@@ -1899,7 +2437,7 @@ app.delete('/api/admin/users/:userId', authMiddleware, adminMiddleware, (req, re
|
||||
*/
|
||||
app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => {
|
||||
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({
|
||||
status,
|
||||
@@ -1908,7 +2446,9 @@ app.get('/api/admin/cases', authMiddleware, adminMiddleware, (req, res) => {
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
sortBy,
|
||||
sortOrder
|
||||
sortOrder,
|
||||
includeArchived: includeArchived === 'true',
|
||||
archivedOnly: archivedOnly === 'true'
|
||||
});
|
||||
|
||||
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
|
||||
// ============================================
|
||||
@@ -2048,6 +2688,15 @@ app.listen(PORT, () => {
|
||||
console.log(' DELETE /api/cases/:id Delete case');
|
||||
console.log(' GET /api/cases/:id/assets Get files');
|
||||
console.log('');
|
||||
console.log('Patient Endpoints:');
|
||||
console.log(' POST /api/patients Create patient');
|
||||
console.log(' GET /api/patients List patients');
|
||||
console.log(' GET /api/patients/:id Get patient');
|
||||
console.log(' PUT /api/patients/:id Update patient');
|
||||
console.log(' DELETE /api/patients/:id Delete patient');
|
||||
console.log(' POST /api/patients/:id/cases Create case for patient');
|
||||
console.log(' GET /api/patients-stats Get patient statistics');
|
||||
console.log('');
|
||||
console.log('Auth Endpoints:');
|
||||
console.log(' POST /api/auth/login Login');
|
||||
console.log(' POST /api/auth/logout Logout');
|
||||
@@ -2061,5 +2710,8 @@ app.listen(PORT, () => {
|
||||
console.log(' GET /api/admin/cases List cases (filtered)');
|
||||
console.log(' GET /api/admin/analytics/dashboard Get dashboard stats');
|
||||
console.log(' GET /api/admin/audit-log Get audit log');
|
||||
console.log(' GET /api/admin/activity Get API request logs');
|
||||
console.log(' GET /api/admin/activity/stats Get API activity stats');
|
||||
console.log(' DELETE /api/admin/activity/cleanup Cleanup old API logs');
|
||||
console.log('');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user