diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c2cee3 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# TransTrack Environment Configuration +# Copy this file to .env and adjust values as needed. + +# ─── Application Mode ─────────────────────────────────────────── +# Set to 'development' for dev features; 'production' for release builds. +NODE_ENV=development + +# Set to '1' to enable Electron dev mode (opens DevTools, loads from localhost) +ELECTRON_DEV=1 + +# ─── Build Configuration ──────────────────────────────────────── +# Build version: 'evaluation' or 'enterprise' +# Normally set by the build scripts (npm run build:eval:* / build:enterprise:*) +# TRANSTRACK_BUILD_VERSION=evaluation + +# ─── License (Development Only) ───────────────────────────────── +# WARNING: Setting this to 'true' bypasses license checks in development. +# This flag is IGNORED when NODE_ENV=production or when the app is packaged. +# NEVER ship a build with this enabled. +# LICENSE_FAIL_OPEN=false + +# ─── EHR Integration (Future / Cloud Mode) ────────────────────── +# These are only used if cloud-based EHR sync is enabled (not in offline mode). +# EHR_WEBHOOK_SECRET= +# EHR_API_KEY_EPIC= +# EHR_API_KEY_CERNER= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47f5365..7d1542f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,25 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + - uses: actions/setup-node@v4 with: node-version: '20' - - - run: npm install --ignore-scripts - + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y python3 make g++ + + - name: Install npm dependencies + run: npm install + + - name: Rebuild native modules for CI Node version + run: npm rebuild better-sqlite3-multiple-ciphers + - run: npm audit || true - + - run: npm run lint || true - + + - name: Run tests + run: npm test + - run: npm run build diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..7870869 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,124 @@ +# TransTrack Architecture + +## System Overview + +TransTrack is an **offline-first, HIPAA-compliant Electron desktop application** for transplant waitlist and operations management. All data is stored locally in an AES-256 encrypted SQLite database. No cloud services are required. + +## High-Level Architecture + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ Renderer Process (React SPA) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐ │ +│ │ Pages │ │ Components │ │ api/localClient.js │ │ +│ │ Dashboard │ │ PatientCard │ │ → window.electronAPI │ │ +│ │ Patients │ │ DonorForm │ │ → IPC invoke │ │ +│ │ Matching │ │ Navbar │ │ │ │ +│ │ Reports │ │ ErrorBound. │ │ TanStack Query caching │ │ +│ │ Settings │ │ 40+ UI │ │ │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────────┬──────────────────┘ │ +│ │ │ │ │ +│ └─────────────────┴──────────────────────┘ │ +│ │ │ +│ contextBridge (preload.cjs) │ +└──────────────────────────────┼────────────────────────────────────────┘ + │ IPC (80+ channels) +┌──────────────────────────────┼────────────────────────────────────────┐ +│ Main Process (Electron) │ │ +│ │ │ +│ ┌───────────────────────────┴──────────────────────────────────────┐ │ +│ │ IPC Handler Coordinator (handlers.cjs) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ auth │ │ entities │ │ admin │ │ license │ │ │ +│ │ ├──────────┤ ├──────────┤ ├──────────┤ ├──────────┤ │ │ +│ │ │ barriers │ │ ahhq │ │ labs │ │ clinical │ │ │ +│ │ ├──────────┤ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │operations│ │ │ +│ │ └──────────┘ ← All share session state via shared.cjs │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴──────────────────────────────────────┐ │ +│ │ Services Layer │ │ +│ │ riskEngine · readinessBarriers · ahhqService · labsService │ │ +│ │ transplantClock · accessControl · disasterRecovery │ │ +│ │ complianceView · offlineReconciliation │ │ +│ └───────────────────────────┬──────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴──────────────────────────────────────┐ │ +│ │ Database Layer │ │ +│ │ init.cjs (key management, encryption, migration) │ │ +│ │ schema.cjs (20+ tables, indexes, foreign keys) │ │ +│ │ SQLCipher (AES-256-CBC, PBKDF2-HMAC-SHA512, 256k iterations) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Data Flow + +### Authentication +1. `AuthContext` → `api.auth.login(credentials)` +2. → IPC `auth:login` → bcrypt verify → session created (8-hour expiry) +3. Session stores `org_id` for downstream org isolation + +### Entity CRUD +1. `api.entities.Patient.list()` → IPC `entity:list` +2. → `getSessionOrgId()` → org-scoped parameterized SQL +3. → Response → TanStack Query cache + +### Business Functions +1. `api.functions.invoke('calculatePriorityAdvanced', { patient_id })` +2. → IPC `function:invoke` → function registry dispatch +3. → Priority scoring algorithm → DB update → audit log + +## Security Architecture + +| Layer | Mechanism | +|-------|-----------| +| **Data at rest** | AES-256-CBC via SQLCipher | +| **Key management** | 256-bit random key, file permissions `0o600` | +| **Org isolation** | `getSessionOrgId()` enforced on all queries; org_id never from client | +| **SQL injection** | Parameterized queries; `ALLOWED_ORDER_COLUMNS` whitelist | +| **Authentication** | bcrypt (cost 12), 8-hour sessions, 5-attempt lockout | +| **Audit trail** | Immutable `audit_logs` table, cannot be modified via API | +| **Access control** | Role-based with break-the-glass justification logging | + +## Module Map + +### Frontend (`src/`) +| Module | Files | Purpose | +|--------|-------|---------| +| Pages | 13 | Dashboard, Patients, DonorMatching, Reports, Settings, etc. | +| Components | 50+ | Domain components + Radix/shadcn UI primitives | +| API | 2 | `localClient.js` (Electron IPC) with dev mock fallback | +| Hooks | 2 | `useIsMobile`, `useJustifiedAccess` | +| Lib | 5 | Auth context, query client, navigation, utils | + +### Electron Main (`electron/`) +| Module | Files | Purpose | +|--------|-------|---------| +| IPC Handlers | 9 modules | Auth, entities, admin, license, barriers, aHHQ, labs, clinical, operations | +| Services | 9 | Risk engine, barriers, aHHQ, labs, clock, access, recovery, compliance, reconciliation | +| Database | 2 | Schema definitions, encryption, migrations | +| License | 3 | Manager, feature gate, tier definitions | +| Functions | 1 | Priority scoring, donor matching, FHIR import | + +## Build Variants + +| Variant | App ID | Restrictions | +|---------|--------|-------------| +| **Evaluation** | `com.transtrack.evaluation` | 14-day trial, 50 patients, 1 user, watermark | +| **Enterprise** | `com.transtrack.enterprise` | Full features, requires license activation | + +## Technology Stack + +| Layer | Technology | +|-------|------------| +| Desktop | Electron 35 | +| Frontend | React 18, Vite 6 | +| Styling | Tailwind CSS, Radix UI (shadcn) | +| State | TanStack React Query v5 | +| Forms | React Hook Form + Zod | +| Database | SQLite via better-sqlite3-multiple-ciphers | +| Charts | Recharts | +| Routing | React Router v6 (HashRouter) | diff --git a/electron/database/init.cjs b/electron/database/init.cjs index 254887c..e0a39ae 100644 --- a/electron/database/init.cjs +++ b/electron/database/init.cjs @@ -552,8 +552,8 @@ async function seedDefaultData(defaultOrgId) { if (!adminExists || adminExists.count === 0) { const bcrypt = require('bcryptjs'); - // Default admin password - CHANGE THIS AFTER FIRST LOGIN! const defaultPassword = 'Admin123!'; + const mustChangePassword = true; // Create default admin user const adminId = uuidv4(); @@ -561,8 +561,8 @@ async function seedDefaultData(defaultOrgId) { const now = new Date().toISOString(); db.prepare(` - INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, must_change_password, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( adminId, defaultOrgId, @@ -571,21 +571,17 @@ async function seedDefaultData(defaultOrgId) { 'System Administrator', 'admin', 1, + mustChangePassword ? 1 : 0, now, now ); - // Log credentials to console for first-time setup - console.log(''); - console.log('╔══════════════════════════════════════════════════════════════╗'); - console.log('║ INITIAL ADMIN CREDENTIALS CREATED ║'); - console.log('╠══════════════════════════════════════════════════════════════╣'); - console.log('║ Email: admin@transtrack.local ║'); - console.log('║ Password: Admin123! ║'); - console.log('╠══════════════════════════════════════════════════════════════╣'); - console.log('║ ⚠️ CHANGE YOUR PASSWORD AFTER FIRST LOGIN! ║'); - console.log('╚══════════════════════════════════════════════════════════════╝'); - console.log(''); + if (process.env.NODE_ENV === 'development') { + console.log(''); + console.log('Initial admin credentials: admin@transtrack.local / Admin123!'); + console.log('CHANGE YOUR PASSWORD AFTER FIRST LOGIN'); + console.log(''); + } // Create default priority weights for this organization const weightsId = uuidv4(); diff --git a/electron/ipc/handlers.cjs b/electron/ipc/handlers.cjs index 1ac85e2..95d72ba 100644 --- a/electron/ipc/handlers.cjs +++ b/electron/ipc/handlers.cjs @@ -1,2007 +1,38 @@ /** - * TransTrack - IPC Handlers - * - * Handles all communication between renderer and main process. - * Implements secure data access with full audit logging. - * + * TransTrack - IPC Handler Coordinator + * + * Registers all domain-specific IPC handler modules. + * Each module handles a specific set of IPC channels. + * * Security Features: * - SQL injection prevention via parameterized queries and column whitelisting * - Session expiration validation * - Account lockout after failed login attempts * - Password strength requirements * - Audit logging for all operations + * - Organization isolation on all data access */ -const { ipcMain, dialog } = require('electron'); -const { - getDatabase, - isEncryptionEnabled, - verifyDatabaseIntegrity, - getEncryptionStatus, - getDefaultOrganization, - getOrgLicense, - getPatientCount, - getUserCount -} = require('../database/init.cjs'); -const { v4: uuidv4 } = require('uuid'); -const bcrypt = require('bcryptjs'); -const licenseManager = require('../license/manager.cjs'); -const featureGate = require('../license/featureGate.cjs'); -const { FEATURES, LICENSE_TIER, LICENSE_FEATURES, hasFeature, checkDataLimit } = require('../license/tiers.cjs'); -const riskEngine = require('../services/riskEngine.cjs'); -const accessControl = require('../services/accessControl.cjs'); -const disasterRecovery = require('../services/disasterRecovery.cjs'); -const complianceView = require('../services/complianceView.cjs'); -const offlineReconciliation = require('../services/offlineReconciliation.cjs'); -const readinessBarriers = require('../services/readinessBarriers.cjs'); -const ahhqService = require('../services/ahhqService.cjs'); -const labsService = require('../services/labsService.cjs'); - -// ============================================================================= -// SESSION STORE (Includes org_id for hard org isolation) -// ============================================================================= -// CRITICAL: Session stores org_id. All downstream operations REQUIRE org_id. -// Never accept org_id from the client. Always use session.org_id. - -let currentSession = null; -let currentUser = null; -let sessionExpiry = null; - -/** - * Get current org_id from session - * FAILS CLOSED if org_id is missing - never returns null/undefined - * @returns {string} The organization ID - * @throws {Error} If no org_id in session - */ -function getSessionOrgId() { - if (!currentUser || !currentUser.org_id) { - throw new Error('Organization context required. Please log in again.'); - } - return currentUser.org_id; -} - -/** - * Get current user's license tier - * @returns {string} The license tier - */ -function getSessionTier() { - if (!currentUser || !currentUser.license_tier) { - return LICENSE_TIER.EVALUATION; - } - return currentUser.license_tier; -} - -/** - * Check if current session has a specific feature enabled - * @param {string} featureName - The feature to check - * @returns {boolean} - */ -function sessionHasFeature(featureName) { - const tier = getSessionTier(); - return hasFeature(tier, featureName); -} - -/** - * Require a feature, throw if not enabled - * @param {string} featureName - The feature to require - * @throws {Error} If feature not enabled - */ -function requireFeature(featureName) { - if (!sessionHasFeature(featureName)) { - const tier = getSessionTier(); - throw new Error(`Feature '${featureName}' is not available in your ${tier} tier. Please upgrade to access this feature.`); - } -} - -// Login security constants -const MAX_LOGIN_ATTEMPTS = 5; -const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes -const SESSION_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours (reduced from 24 for security) - -// Allowed columns for ORDER BY to prevent SQL injection -// Note: org_id is NOT included as it should never be used for sorting (it's always filtered, not sorted) -const ALLOWED_ORDER_COLUMNS = { - patients: ['id', 'patient_id', 'first_name', 'last_name', 'blood_type', 'organ_needed', 'medical_urgency', 'waitlist_status', 'priority_score', 'date_of_birth', 'email', 'phone', 'created_at', 'updated_at'], - donor_organs: ['id', 'donor_id', 'organ_type', 'blood_type', 'organ_status', 'status', 'patient_id', 'created_at', 'updated_at'], - matches: ['id', 'donor_organ_id', 'patient_id', 'patient_name', 'compatibility_score', 'match_status', 'priority_rank', 'created_at', 'updated_at'], - notifications: ['id', 'recipient_email', 'title', 'notification_type', 'is_read', 'priority_level', 'related_patient_id', 'created_at'], - notification_rules: ['id', 'rule_name', 'trigger_event', 'priority_level', 'is_active', 'created_at', 'updated_at'], - priority_weights: ['id', 'name', 'is_active', 'created_at', 'updated_at'], - ehr_integrations: ['id', 'name', 'type', 'is_active', 'last_sync_date', 'base_url', 'sync_frequency_minutes', 'created_at', 'updated_at'], - ehr_imports: ['id', 'integration_id', 'import_type', 'status', 'created_at', 'completed_date'], - ehr_sync_logs: ['id', 'integration_id', 'sync_type', 'direction', 'status', 'created_at', 'completed_date'], - ehr_validation_rules: ['id', 'field_name', 'rule_type', 'is_active', 'created_at', 'updated_at'], - audit_logs: ['id', 'action', 'entity_type', 'entity_id', 'patient_name', 'user_id', 'user_email', 'user_role', 'created_at'], - users: ['id', 'email', 'full_name', 'role', 'is_active', 'created_at', 'updated_at', 'last_login'], - readiness_barriers: ['id', 'patient_id', 'barrier_type', 'status', 'risk_level', 'owning_role', 'created_at', 'updated_at'], - adult_health_history_questionnaires: ['id', 'patient_id', 'status', 'expiration_date', 'owning_role', 'created_at', 'updated_at'], - organizations: ['id', 'name', 'type', 'status', 'created_at', 'updated_at'], - licenses: ['id', 'tier', 'activated_at', 'license_expires_at', 'created_at', 'updated_at'], - settings: ['id', 'key', 'value', 'updated_at'], - lab_results: ['id', 'patient_id', 'test_code', 'test_name', 'collected_at', 'resulted_at', 'source', 'created_at', 'updated_at'], - required_lab_types: ['id', 'test_code', 'test_name', 'organ_type', 'max_age_days', 'is_active', 'created_at', 'updated_at'], -}; - -// Password strength requirements -const PASSWORD_REQUIREMENTS = { - minLength: 12, - requireUppercase: true, - requireLowercase: true, - requireNumber: true, - requireSpecial: true, -}; - -/** - * Validate password strength - * @param {string} password - The password to validate - * @returns {{ valid: boolean, errors: string[] }} - */ -function validatePasswordStrength(password) { - const errors = []; - - if (!password || password.length < PASSWORD_REQUIREMENTS.minLength) { - errors.push(`Password must be at least ${PASSWORD_REQUIREMENTS.minLength} characters`); - } - if (PASSWORD_REQUIREMENTS.requireUppercase && !/[A-Z]/.test(password)) { - errors.push('Password must contain at least one uppercase letter'); - } - if (PASSWORD_REQUIREMENTS.requireLowercase && !/[a-z]/.test(password)) { - errors.push('Password must contain at least one lowercase letter'); - } - if (PASSWORD_REQUIREMENTS.requireNumber && !/[0-9]/.test(password)) { - errors.push('Password must contain at least one number'); - } - if (PASSWORD_REQUIREMENTS.requireSpecial && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { - errors.push('Password must contain at least one special character (!@#$%^&*...)'); - } - - return { valid: errors.length === 0, errors }; -} - -/** - * Check if account is locked due to failed login attempts - * Uses database for persistence across application restarts - * @param {string} email - The email to check - * @returns {{ locked: boolean, remainingTime: number }} - */ -function checkAccountLockout(email) { - const db = getDatabase(); - const normalizedEmail = email.toLowerCase().trim(); - - const attempt = db.prepare(` - SELECT * FROM login_attempts WHERE email = ? - `).get(normalizedEmail); - - if (!attempt) return { locked: false, remainingTime: 0 }; - - if (attempt.locked_until) { - const lockedUntil = new Date(attempt.locked_until).getTime(); - const now = Date.now(); - - if (now < lockedUntil) { - return { - locked: true, - remainingTime: Math.ceil((lockedUntil - now) / 1000 / 60) // minutes - }; - } - - // Lockout has expired - clear it - db.prepare(` - UPDATE login_attempts SET attempt_count = 0, locked_until = NULL, updated_at = datetime('now') - WHERE email = ? - `).run(normalizedEmail); - return { locked: false, remainingTime: 0 }; - } - - return { locked: false, remainingTime: 0 }; -} - -/** - * Record a failed login attempt (persisted in database) - * @param {string} email - The email that failed to login - * @param {string} ipAddress - IP address of the attempt (optional) - */ -function recordFailedLogin(email, ipAddress = null) { - const db = getDatabase(); - const normalizedEmail = email.toLowerCase().trim(); - const now = new Date().toISOString(); - - const existing = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(normalizedEmail); - - if (existing) { - const newCount = existing.attempt_count + 1; - let lockedUntil = null; - - if (newCount >= MAX_LOGIN_ATTEMPTS) { - lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS).toISOString(); - } - - db.prepare(` - UPDATE login_attempts SET - attempt_count = ?, - last_attempt_at = ?, - locked_until = ?, - ip_address = COALESCE(?, ip_address), - updated_at = ? - WHERE email = ? - `).run(newCount, now, lockedUntil, ipAddress, now, normalizedEmail); - } else { - const id = uuidv4(); - db.prepare(` - INSERT INTO login_attempts (id, email, attempt_count, last_attempt_at, ip_address, created_at, updated_at) - VALUES (?, ?, 1, ?, ?, ?, ?) - `).run(id, normalizedEmail, now, ipAddress, now, now); - } -} - -/** - * Clear failed login attempts after successful login (persisted in database) - * @param {string} email - The email to clear - */ -function clearFailedLogins(email) { - const db = getDatabase(); - const normalizedEmail = email.toLowerCase().trim(); - - db.prepare('DELETE FROM login_attempts WHERE email = ?').run(normalizedEmail); -} - -/** - * Validate session is still active and not expired - * Also validates that org_id is present in session - * @returns {boolean} - */ -function validateSession() { - if (!currentSession || !currentUser || !sessionExpiry) { - return false; - } - - if (Date.now() > sessionExpiry) { - // Session expired - clear it - currentSession = null; - currentUser = null; - sessionExpiry = null; - return false; - } - - // Validate org_id is present (fail closed) - if (!currentUser.org_id) { - currentSession = null; - currentUser = null; - sessionExpiry = null; - return false; - } - - return true; -} - -/** - * Validate ORDER BY column against whitelist to prevent SQL injection - * @param {string} tableName - The table name - * @param {string} column - The column name to validate - * @returns {boolean} - */ -function isValidOrderColumn(tableName, column) { - const allowedColumns = ALLOWED_ORDER_COLUMNS[tableName]; - if (!allowedColumns) return false; - return allowedColumns.includes(column); -} - -// Entity name to table name mapping -const entityTableMap = { - Patient: 'patients', - DonorOrgan: 'donor_organs', - Match: 'matches', - Notification: 'notifications', - NotificationRule: 'notification_rules', - PriorityWeights: 'priority_weights', - EHRIntegration: 'ehr_integrations', - EHRImport: 'ehr_imports', - EHRSyncLog: 'ehr_sync_logs', - EHRValidationRule: 'ehr_validation_rules', - AuditLog: 'audit_logs', - User: 'users', - ReadinessBarrier: 'readiness_barriers', - AdultHealthHistoryQuestionnaire: 'adult_health_history_questionnaires' -}; - -// Fields that store JSON data -const jsonFields = ['priority_score_breakdown', 'conditions', 'notification_template', 'metadata', 'import_data', 'error_details', 'document_urls', 'identified_issues']; +const authHandlers = require('./handlers/auth.cjs'); +const entityHandlers = require('./handlers/entities.cjs'); +const adminHandlers = require('./handlers/admin.cjs'); +const licenseHandlers = require('./handlers/license.cjs'); +const barrierHandlers = require('./handlers/barriers.cjs'); +const ahhqHandlers = require('./handlers/ahhq.cjs'); +const labsHandlers = require('./handlers/labs.cjs'); +const clinicalHandlers = require('./handlers/clinical.cjs'); +const operationsHandlers = require('./handlers/operations.cjs'); function setupIPCHandlers() { - const db = getDatabase(); - - // ===== APP INFO ===== - ipcMain.handle('app:getInfo', () => ({ - name: 'TransTrack', - version: '1.0.0', - compliance: ['HIPAA', 'FDA 21 CFR Part 11', 'AATB'], - encryptionEnabled: isEncryptionEnabled() - })); - - ipcMain.handle('app:getVersion', () => '1.0.0'); - - // ===== DATABASE ENCRYPTION STATUS (HIPAA Compliance) ===== - ipcMain.handle('encryption:getStatus', async () => { - return getEncryptionStatus(); - }); - - ipcMain.handle('encryption:verifyIntegrity', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - const result = verifyDatabaseIntegrity(); - - // Log the verification - logAudit('encryption_verify', 'System', null, null, - `Database integrity check: ${result.valid ? 'PASSED' : 'FAILED'}`, - currentUser.email, currentUser.role); - - return result; - }); - - ipcMain.handle('encryption:isEnabled', async () => { - return isEncryptionEnabled(); - }); - - // ===== ORGANIZATION MANAGEMENT ===== - - ipcMain.handle('organization:getCurrent', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const orgId = getSessionOrgId(); - const org = db.prepare('SELECT * FROM organizations WHERE id = ?').get(orgId); - - if (!org) { - throw new Error('Organization not found'); - } - - // Get license info - const license = getOrgLicense(orgId); - - // Get counts for limits display - const patientCount = getPatientCount(orgId); - const userCount = getUserCount(orgId); - - return { - ...org, - license: license ? { - tier: license.tier, - maxPatients: license.max_patients, - maxUsers: license.max_users, - expiresAt: license.license_expires_at, - maintenanceExpiresAt: license.maintenance_expires_at, - } : null, - usage: { - patients: patientCount, - users: userCount, - }, - }; - }); - - ipcMain.handle('organization:update', async (event, updates) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - const orgId = getSessionOrgId(); - const now = new Date().toISOString(); - - // Only allow updating certain fields - const allowedFields = ['name', 'address', 'phone', 'email', 'settings']; - const safeUpdates = {}; - - for (const field of allowedFields) { - if (updates[field] !== undefined) { - safeUpdates[field] = updates[field]; - } - } - - if (Object.keys(safeUpdates).length === 0) { - throw new Error('No valid fields to update'); - } - - // Handle settings as JSON - if (safeUpdates.settings && typeof safeUpdates.settings === 'object') { - safeUpdates.settings = JSON.stringify(safeUpdates.settings); - } - - const setClause = Object.keys(safeUpdates).map(k => `${k} = ?`).join(', '); - const values = [...Object.values(safeUpdates), now, orgId]; - - db.prepare(`UPDATE organizations SET ${setClause}, updated_at = ? WHERE id = ?`).run(...values); - - logAudit('update', 'Organization', orgId, null, 'Organization settings updated', currentUser.email, currentUser.role); - - return { success: true }; - }); - - // ===== LICENSE MANAGEMENT ===== - - ipcMain.handle('license:getInfo', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const orgId = getSessionOrgId(); - const license = getOrgLicense(orgId); - const tier = license?.tier || LICENSE_TIER.EVALUATION; - const features = LICENSE_FEATURES[tier] || LICENSE_FEATURES[LICENSE_TIER.EVALUATION]; - - return { - tier: tier, - features: features, - license: license, - usage: { - patients: getPatientCount(orgId), - users: getUserCount(orgId), - }, - limits: { - maxPatients: features.maxPatients, - maxUsers: features.maxUsers, - }, - }; - }); - - ipcMain.handle('license:activate', async (event, licenseKey, customerInfo) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - const orgId = getSessionOrgId(); - - // Check build type - evaluation builds cannot activate licenses - const { isEvaluationBuild } = require('../license/tiers.cjs'); - if (isEvaluationBuild()) { - throw new Error('Cannot activate license on Evaluation build. Please download the Enterprise version.'); - } - - // Activate using license manager - const result = await licenseManager.activateLicense(licenseKey, { - ...customerInfo, - orgId: orgId, - }); - - if (result.success) { - // Update license in database - const now = new Date().toISOString(); - const existingLicense = getOrgLicense(orgId); - - if (existingLicense) { - db.prepare(` - UPDATE licenses SET - license_key = ?, tier = ?, activated_at = ?, maintenance_expires_at = ?, - customer_name = ?, customer_email = ?, updated_at = ? - WHERE org_id = ? - `).run( - licenseKey, result.tier, now, result.maintenanceExpiry, - customerInfo?.name || '', customerInfo?.email || '', now, orgId - ); - } else { - db.prepare(` - INSERT INTO licenses (id, org_id, license_key, tier, activated_at, maintenance_expires_at, customer_name, customer_email, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - uuidv4(), orgId, licenseKey, result.tier, now, result.maintenanceExpiry, - customerInfo?.name || '', customerInfo?.email || '', now, now - ); - } - - logAudit('license_activated', 'License', orgId, null, `License activated: ${result.tier}`, currentUser.email, currentUser.role); - } - - return result; - }); - - ipcMain.handle('license:checkFeature', async (event, featureName) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - return { - enabled: sessionHasFeature(featureName), - tier: getSessionTier(), - }; - }); - - // ===== SETTINGS (Org-Scoped) ===== - - ipcMain.handle('settings:get', async (event, key) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const orgId = getSessionOrgId(); - const setting = db.prepare('SELECT value FROM settings WHERE org_id = ? AND key = ?').get(orgId, key); - - if (!setting) return null; - - try { - return JSON.parse(setting.value); - } catch { - return setting.value; - } - }); - - ipcMain.handle('settings:set', async (event, key, value) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - const orgId = getSessionOrgId(); - const now = new Date().toISOString(); - const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value); - - // Upsert the setting - const existing = db.prepare('SELECT id FROM settings WHERE org_id = ? AND key = ?').get(orgId, key); - - if (existing) { - db.prepare('UPDATE settings SET value = ?, updated_at = ? WHERE id = ?').run(valueStr, now, existing.id); - } else { - db.prepare('INSERT INTO settings (id, org_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)').run( - uuidv4(), orgId, key, valueStr, now - ); - } - - logAudit('settings_update', 'Settings', key, null, `Setting '${key}' updated`, currentUser.email, currentUser.role); - - return { success: true }; - }); - - ipcMain.handle('settings:getAll', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const orgId = getSessionOrgId(); - const settings = db.prepare('SELECT key, value FROM settings WHERE org_id = ?').all(orgId); - - const result = {}; - for (const setting of settings) { - try { - result[setting.key] = JSON.parse(setting.value); - } catch { - result[setting.key] = setting.value; - } - } - - return result; - }); - - // ===== AUTHENTICATION ===== - ipcMain.handle('auth:login', async (event, { email, password }) => { - try { - // Check for account lockout - const lockoutStatus = checkAccountLockout(email); - if (lockoutStatus.locked) { - logAudit('login_blocked', 'User', null, null, `Login blocked: account locked for ${lockoutStatus.remainingTime} more minutes`, email, null); - throw new Error(`Account temporarily locked due to too many failed attempts. Try again in ${lockoutStatus.remainingTime} minutes.`); - } - - // Find user by email (email is unique per org, but we allow login with just email) - const user = db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email); - - if (!user) { - recordFailedLogin(email); - logAudit('login_failed', 'User', null, null, 'Login failed: user not found', email, null); - throw new Error('Invalid credentials'); - } - - const isValid = await bcrypt.compare(password, user.password_hash); - if (!isValid) { - recordFailedLogin(email); - logAudit('login_failed', 'User', null, null, 'Login failed: invalid password', email, null); - throw new Error('Invalid credentials'); - } - - // CRITICAL: Get user's organization - if (!user.org_id) { - // Legacy user without org - assign to default organization - const defaultOrg = getDefaultOrganization(); - if (defaultOrg) { - db.prepare('UPDATE users SET org_id = ? WHERE id = ?').run(defaultOrg.id, user.id); - user.org_id = defaultOrg.id; - } else { - throw new Error('No organization configured. Please contact administrator.'); - } - } - - // Get organization info - const org = db.prepare('SELECT * FROM organizations WHERE id = ?').get(user.org_id); - if (!org || org.status !== 'ACTIVE') { - throw new Error('Your organization is not active. Please contact administrator.'); - } - - // Get organization's license - const license = getOrgLicense(user.org_id); - const licenseTier = license?.tier || LICENSE_TIER.EVALUATION; - - // Clear failed login attempts on successful login - clearFailedLogins(email); - - // Create session with org_id - const sessionId = uuidv4(); - const expiresAtDate = new Date(Date.now() + SESSION_DURATION_MS); - const expiresAt = expiresAtDate.toISOString(); - - db.prepare(` - INSERT INTO sessions (id, user_id, org_id, expires_at) - VALUES (?, ?, ?, ?) - `).run(sessionId, user.id, user.org_id, expiresAt); - - // Update last login - db.prepare("UPDATE users SET last_login = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(user.id); - - // Store current session with expiry and org_id - // CRITICAL: Session stores org_id - all downstream operations use this - currentSession = sessionId; - sessionExpiry = expiresAtDate.getTime(); - currentUser = { - id: user.id, - email: user.email, - full_name: user.full_name, - role: user.role, - org_id: user.org_id, // REQUIRED for org isolation - org_name: org.name, // For display - license_tier: licenseTier, // For feature gating - }; - - // Log login (without sensitive details) - logAudit('login', 'User', user.id, null, 'User logged in successfully', user.email, user.role); - - return { success: true, user: currentUser }; - } catch (error) { - // Don't expose internal error details to client - const safeMessage = error.message.includes('locked') || - error.message === 'Invalid credentials' || - error.message.includes('organization') - ? error.message - : 'Authentication failed'; - throw new Error(safeMessage); - } - }); - - ipcMain.handle('auth:logout', async () => { - if (currentSession) { - db.prepare('DELETE FROM sessions WHERE id = ?').run(currentSession); - logAudit('logout', 'User', currentUser?.id, null, 'User logged out', currentUser?.email, currentUser?.role); - } - currentSession = null; - currentUser = null; - return { success: true }; - }); - - ipcMain.handle('auth:me', async () => { - if (!validateSession()) { - currentSession = null; - currentUser = null; - sessionExpiry = null; - throw new Error('Session expired. Please log in again.'); - } - return currentUser; - }); - - ipcMain.handle('auth:isAuthenticated', async () => { - return validateSession(); - }); - - ipcMain.handle('auth:register', async (event, userData) => { - // Get or create default organization for first-time setup - let defaultOrg = getDefaultOrganization(); - - // Check if registration is allowed - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get(); - - // Only allow registration if no users exist (first-time setup) or if called by admin - if (userCount.count > 0 && (!currentUser || currentUser.role !== 'admin')) { - throw new Error('Registration not allowed. Please contact administrator.'); - } - - // If this is first user, they must create an organization first - if (!defaultOrg) { - // Create default organization for this installation - const { createDefaultOrganization } = require('../database/init.cjs'); - defaultOrg = createDefaultOrganization(); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(userData.email)) { - throw new Error('Invalid email format'); - } - - // Validate password strength - const passwordValidation = validatePasswordStrength(userData.password); - if (!passwordValidation.valid) { - throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); - } - - // Validate full name - if (!userData.full_name || userData.full_name.trim().length < 2) { - throw new Error('Full name must be at least 2 characters'); - } - - const hashedPassword = await bcrypt.hash(userData.password, 12); - const userId = uuidv4(); - const now = new Date().toISOString(); - - // CRITICAL: Always associate user with an organization - const orgId = currentUser?.org_id || defaultOrg.id; - - db.prepare(` - INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(userId, orgId, userData.email, hashedPassword, userData.full_name.trim(), userData.role || 'admin', 1, now, now); - - logAudit('create', 'User', userId, null, 'User registered', userData.email, userData.role || 'admin'); - - return { success: true, id: userId }; - }); - - ipcMain.handle('auth:changePassword', async (event, { currentPassword, newPassword }) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - // Validate new password strength - const passwordValidation = validatePasswordStrength(newPassword); - if (!passwordValidation.valid) { - throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); - } - - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(currentUser.id); - const isValid = await bcrypt.compare(currentPassword, user.password_hash); - - if (!isValid) { - throw new Error('Current password is incorrect'); - } - - const hashedPassword = await bcrypt.hash(newPassword, 12); - db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") - .run(hashedPassword, currentUser.id); - - logAudit('update', 'User', currentUser.id, null, 'Password changed', currentUser.email, currentUser.role); - - return { success: true }; - }); - - ipcMain.handle('auth:createUser', async (event, userData) => { - if (!validateSession() || currentUser.role !== 'admin') { - throw new Error('Unauthorized: Admin access required'); - } - - const orgId = getSessionOrgId(); // CRITICAL: Use session org_id, never from client - - // Check user limit for this organization - const userCount = getUserCount(orgId); - const tier = getSessionTier(); - const limitCheck = checkDataLimit(tier, 'maxUsers', userCount); - if (!limitCheck.allowed) { - throw new Error(`User limit reached (${limitCheck.limit}). Please upgrade your license to add more users.`); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(userData.email)) { - throw new Error('Invalid email format'); - } - - // Check email uniqueness within organization (not global) - const existingUser = db.prepare(` - SELECT id FROM users WHERE org_id = ? AND email = ? - `).get(orgId, userData.email); - - if (existingUser) { - throw new Error('A user with this email already exists in your organization.'); - } - - // Validate password strength - const passwordValidation = validatePasswordStrength(userData.password); - if (!passwordValidation.valid) { - throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); - } - - const hashedPassword = await bcrypt.hash(userData.password, 12); - const userId = uuidv4(); - const now = new Date().toISOString(); - - db.prepare(` - INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(userId, orgId, userData.email, hashedPassword, userData.full_name, userData.role || 'user', 1, now, now); - - logAudit('create', 'User', userId, null, 'User created', currentUser.email, currentUser.role); - - return { success: true, id: userId }; - }); - - ipcMain.handle('auth:listUsers', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const orgId = getSessionOrgId(); // CRITICAL: Only return users from current org - - const users = db.prepare(` - SELECT id, email, full_name, role, is_active, created_at, last_login - FROM users - WHERE org_id = ? - ORDER BY created_at DESC - `).all(orgId); - - return users; - }); - - ipcMain.handle('auth:updateUser', async (event, id, userData) => { - if (!validateSession() || (currentUser.role !== 'admin' && currentUser.id !== id)) { - throw new Error('Unauthorized'); - } - - const updates = []; - const values = []; - - if (userData.full_name !== undefined) { - updates.push('full_name = ?'); - values.push(userData.full_name); - } - if (userData.role !== undefined && currentUser.role === 'admin') { - // Validate role value - const validRoles = ['admin', 'coordinator', 'physician', 'user', 'viewer', 'regulator']; - if (!validRoles.includes(userData.role)) { - throw new Error('Invalid role specified'); - } - updates.push('role = ?'); - values.push(userData.role); - } - if (userData.is_active !== undefined && currentUser.role === 'admin') { - updates.push('is_active = ?'); - values.push(userData.is_active ? 1 : 0); - - // If deactivating user, invalidate their sessions - if (!userData.is_active) { - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); - logAudit('session_invalidated', 'User', id, null, 'User sessions invalidated due to account deactivation', currentUser.email, currentUser.role); - } - } - - if (updates.length > 0) { - updates.push("updated_at = datetime('now')"); - values.push(id); - - db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); - logAudit('update', 'User', id, null, 'User updated', currentUser.email, currentUser.role); - } - - return { success: true }; - }); - - ipcMain.handle('auth:deleteUser', async (event, id) => { - if (!validateSession() || currentUser.role !== 'admin') { - throw new Error('Unauthorized: Admin access required'); - } - - if (id === currentUser.id) { - throw new Error('Cannot delete your own account'); - } - - const user = db.prepare('SELECT email FROM users WHERE id = ?').get(id); - - // Delete user's sessions first - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); - - // Delete the user - db.prepare('DELETE FROM users WHERE id = ?').run(id); - - logAudit('delete', 'User', id, null, 'User deleted', currentUser.email, currentUser.role); - - return { success: true }; - }); - - // ===== ENTITY OPERATIONS ===== - // CRITICAL: All entity operations enforce org isolation using session.org_id - // Never accept org_id from client data - always use getSessionOrgId() - - ipcMain.handle('entity:create', async (event, entityName, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Get org_id from session, never from client - const orgId = getSessionOrgId(); - const tier = getSessionTier(); - - // Prevent creation of audit logs via generic handler (HIPAA compliance) - // Audit logs should only be created internally via logAudit function - if (entityName === 'AuditLog') { - throw new Error('Audit logs cannot be created directly'); - } - - // Check license limits for patients and donors (org-scoped) - try { - if (entityName === 'Patient') { - const currentCount = getPatientCount(orgId); - const limitCheck = checkDataLimit(tier, 'maxPatients', currentCount); - if (!limitCheck.allowed) { - throw new Error(`Patient limit reached (${limitCheck.limit}). Please upgrade your license to add more patients.`); - } - } - - if (entityName === 'DonorOrgan') { - const currentCount = db.prepare('SELECT COUNT(*) as count FROM donor_organs WHERE org_id = ?').get(orgId).count; - const limitCheck = checkDataLimit(tier, 'maxDonors', currentCount); - if (!limitCheck.allowed) { - throw new Error(`Donor limit reached (${limitCheck.limit}). Please upgrade your license to add more donors.`); - } - } - - // Check write access (not in read-only mode) - if (featureGate.isReadOnlyMode()) { - throw new Error('Application is in read-only mode. Please activate or renew your license to make changes.'); - } - } catch (licenseError) { - // SECURITY: Fail closed on license errors - // Only fail-open with explicit dev flag - const failOpen = process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true'; - - if (!failOpen) { - // Log and re-throw all license errors in production - console.error('License check error:', licenseError.message); - throw licenseError; - } - - // In dev mode with fail-open flag, only block on explicit limits - console.warn('License check warning (dev mode):', licenseError.message); - if (licenseError.message.includes('limit reached') || - licenseError.message.includes('read-only mode')) { - throw licenseError; - } - } - - // Generate ID if not provided - const id = data.id || uuidv4(); - - // CRITICAL: Add org_id to entity data (enforces org isolation) - // Remove any client-provided org_id to prevent cross-org data injection - delete data.org_id; - const entityData = { ...data, id, org_id: orgId, created_by: currentUser.email }; - - // Sanitize all values for SQLite compatibility - // SQLite only accepts: numbers, strings, bigints, buffers, and null - for (const field of Object.keys(entityData)) { - const value = entityData[field]; - - // Convert undefined to null - if (value === undefined) { - entityData[field] = null; - continue; - } - - // Convert booleans to integers (SQLite doesn't support booleans) - if (typeof value === 'boolean') { - entityData[field] = value ? 1 : 0; - continue; - } - - // Convert arrays and objects to JSON strings - if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { - entityData[field] = JSON.stringify(value); - continue; - } - - // Keep numbers, strings, bigints, buffers, and null as-is - } - - // Build insert statement - const fields = Object.keys(entityData); - const placeholders = fields.map(() => '?').join(', '); - const values = fields.map(f => entityData[f]); - - try { - db.prepare(`INSERT INTO ${tableName} (${fields.join(', ')}) VALUES (${placeholders})`).run(...values); - } catch (dbError) { - // Provide user-friendly error messages for common database errors - if (dbError.code === 'SQLITE_CONSTRAINT_UNIQUE') { - if (entityName === 'Patient' && entityData.patient_id) { - throw new Error(`A patient with ID "${entityData.patient_id}" already exists. Please use a unique Patient ID.`); - } else if (entityName === 'DonorOrgan' && entityData.donor_id) { - throw new Error(`A donor with ID "${entityData.donor_id}" already exists. Please use a unique Donor ID.`); - } else { - throw new Error(`A ${entityName} with this identifier already exists.`); - } - } - throw dbError; - } - - // Get patient name for audit log - let patientName = null; - if (entityName === 'Patient') { - patientName = `${data.first_name} ${data.last_name}`; - } else if (data.patient_name) { - patientName = data.patient_name; - } - - logAudit('create', entityName, id, patientName, `${entityName} created`, currentUser.email, currentUser.role); - - // Return created entity - return getEntityById(tableName, id); - }); - - ipcMain.handle('entity:get', async (event, entityName, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Get entity only if it belongs to current org - const orgId = getSessionOrgId(); - return getEntityByIdAndOrg(tableName, id, orgId); - }); - - ipcMain.handle('entity:update', async (event, entityName, id, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Enforce org isolation - const orgId = getSessionOrgId(); - - // Prevent modification of audit logs (HIPAA compliance) - if (entityName === 'AuditLog') { - throw new Error('Audit logs cannot be modified'); - } - - // CRITICAL: Verify entity belongs to user's organization before update - const existingEntity = getEntityByIdAndOrg(tableName, id, orgId); - if (!existingEntity) { - throw new Error(`${entityName} not found or access denied`); - } - - const now = new Date().toISOString(); - const entityData = { ...data, updated_by: currentUser.email, updated_at: now }; - - // Remove fields that should not be updated - delete entityData.id; - delete entityData.org_id; // CRITICAL: Never allow org_id change - delete entityData.created_at; - delete entityData.created_date; - delete entityData.created_by; - - // Sanitize all values for SQLite compatibility - // SQLite only accepts: numbers, strings, bigints, buffers, and null - for (const field of Object.keys(entityData)) { - const value = entityData[field]; - - // Convert undefined to null - if (value === undefined) { - entityData[field] = null; - continue; - } - - // Convert booleans to integers (SQLite doesn't support booleans) - if (typeof value === 'boolean') { - entityData[field] = value ? 1 : 0; - continue; - } - - // Convert arrays and objects to JSON strings - if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { - entityData[field] = JSON.stringify(value); - continue; - } - - // Keep numbers, strings, bigints, buffers, and null as-is - } - - // Build update statement with org_id check - const updates = Object.keys(entityData).map(k => `${k} = ?`).join(', '); - const values = [...Object.values(entityData), id, orgId]; - - // CRITICAL: WHERE clause includes org_id to prevent cross-org updates - db.prepare(`UPDATE ${tableName} SET ${updates} WHERE id = ? AND org_id = ?`).run(...values); - - // Get updated entity - const entity = getEntityByIdAndOrg(tableName, id, orgId); - let patientName = null; - if (entityName === 'Patient') { - patientName = `${entity.first_name} ${entity.last_name}`; - } else if (entity.patient_name) { - patientName = entity.patient_name; - } - - logAudit('update', entityName, id, patientName, `${entityName} updated`, currentUser.email, currentUser.role); - - return entity; - }); - - ipcMain.handle('entity:delete', async (event, entityName, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Enforce org isolation - const orgId = getSessionOrgId(); - - // Prevent deletion of audit logs (HIPAA compliance) - if (entityName === 'AuditLog') { - throw new Error('Audit logs cannot be deleted'); - } - - // CRITICAL: Verify entity belongs to user's organization before delete - const entity = getEntityByIdAndOrg(tableName, id, orgId); - if (!entity) { - throw new Error(`${entityName} not found or access denied`); - } - - let patientName = null; - if (entityName === 'Patient' && entity) { - patientName = `${entity.first_name} ${entity.last_name}`; - } else if (entity?.patient_name) { - patientName = entity.patient_name; - } - - // CRITICAL: DELETE includes org_id check to prevent cross-org deletes - db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND org_id = ?`).run(id, orgId); - - logAudit('delete', entityName, id, patientName, `${entityName} deleted`, currentUser.email, currentUser.role); - - return { success: true }; - }); - - ipcMain.handle('entity:list', async (event, entityName, orderBy, limit) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Enforce org isolation - only return data from user's organization - const orgId = getSessionOrgId(); - - return listEntitiesByOrg(tableName, orgId, orderBy, limit); - }); - - ipcMain.handle('entity:filter', async (event, entityName, filters, orderBy, limit) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - - const tableName = entityTableMap[entityName]; - if (!tableName) throw new Error(`Unknown entity: ${entityName}`); - - // CRITICAL: Enforce org isolation - filter must include org_id - const orgId = getSessionOrgId(); - - // Get allowed columns for this table to validate filter keys - const allowedColumns = ALLOWED_ORDER_COLUMNS[tableName] || []; - - // CRITICAL: Always filter by org_id first - let query = `SELECT * FROM ${tableName} WHERE org_id = ?`; - const values = [orgId]; - - // Build additional WHERE conditions with column validation - if (filters && typeof filters === 'object') { - // Remove any client-provided org_id to prevent cross-org access - delete filters.org_id; - - for (const [key, value] of Object.entries(filters)) { - if (value !== undefined && value !== null) { - // Validate filter column name to prevent SQL injection - if (!allowedColumns.includes(key) && !['id', 'created_at', 'updated_at'].includes(key)) { - throw new Error(`Invalid filter field: ${key}`); - } - query += ` AND ${key} = ?`; - values.push(value); - } - } - } - - // Handle ordering with SQL injection prevention - if (orderBy) { - const desc = orderBy.startsWith('-'); - const field = desc ? orderBy.substring(1) : orderBy; - - // Validate column name against whitelist - if (!isValidOrderColumn(tableName, field)) { - throw new Error(`Invalid sort field: ${field}`); - } - - query += ` ORDER BY ${field} ${desc ? 'DESC' : 'ASC'}`; - } else { - query += ' ORDER BY created_at DESC'; - } - - // Handle limit with bounds validation - if (limit) { - const parsedLimit = parseInt(limit, 10); - if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) { - throw new Error('Invalid limit value. Must be between 1 and 10000.'); - } - query += ` LIMIT ${parsedLimit}`; - } - - const rows = db.prepare(query).all(...values); - return rows.map(row => parseJsonFields(row)); - }); - - // ===== FUNCTIONS (Business Logic) ===== - ipcMain.handle('function:invoke', async (event, functionName, params) => { - if (!currentUser) throw new Error('Not authenticated'); - - const functions = require('../functions/index.cjs'); - - if (!functions[functionName]) { - throw new Error(`Unknown function: ${functionName}`); - } - - const result = await functions[functionName](params, { db, currentUser, logAudit }); - return result; - }); - - // NOTE: Settings handlers are defined in the ORG-SCOPED SETTINGS section above. - // Do NOT add duplicate handlers here - they would bypass org isolation. - - // ===== OPERATIONAL RISK INTELLIGENCE ===== - ipcMain.handle('risk:getDashboard', async () => { - return await riskEngine.getRiskDashboard(); - }); - - ipcMain.handle('risk:getFullReport', async () => { - return await riskEngine.generateOperationalRiskReport(); - }); - - ipcMain.handle('risk:assessPatient', async (event, patientId) => { - const patient = db.prepare('SELECT * FROM patients WHERE id = ?').get(patientId); - if (!patient) throw new Error('Patient not found'); - return riskEngine.assessPatientOperationalRisk(patient); - }); - - // ===== READINESS BARRIERS (Non-Clinical Operational Tracking) ===== - // NOTE: This feature is strictly NON-CLINICAL, NON-ALLOCATIVE, and designed for - // operational workflow visibility only. It does NOT perform allocation decisions, - // listing authority functions, or replace UNOS/OPTN systems. - - ipcMain.handle('barrier:getTypes', async () => { - return readinessBarriers.BARRIER_TYPES; - }); - - ipcMain.handle('barrier:getStatuses', async () => { - return readinessBarriers.BARRIER_STATUS; - }); - - ipcMain.handle('barrier:getRiskLevels', async () => { - return readinessBarriers.BARRIER_RISK_LEVEL; - }); - - ipcMain.handle('barrier:getOwningRoles', async () => { - return readinessBarriers.OWNING_ROLES; - }); - - ipcMain.handle('barrier:create', async (event, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - // Validate required fields - if (!data.patient_id) throw new Error('Patient ID is required'); - if (!data.barrier_type) throw new Error('Barrier type is required'); - if (!data.owning_role) throw new Error('Owning role is required'); - - // Validate notes length (max 255 chars, non-clinical only) - if (data.notes && data.notes.length > 255) { - throw new Error('Notes must be 255 characters or less'); - } - - const barrier = readinessBarriers.createBarrier(data, currentUser.id, orgId); - - // Get patient name for audit (org-scoped) - const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(data.patient_id, orgId); - const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; - - logAudit( - 'create', 'ReadinessBarrier', barrier.id, patientName, - JSON.stringify({ patient_id: data.patient_id, barrier_type: data.barrier_type, status: barrier.status, risk_level: barrier.risk_level }), - currentUser.email, currentUser.role - ); - - return barrier; - }); - - ipcMain.handle('barrier:update', async (event, id, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - const existing = readinessBarriers.getBarrierById(id, orgId); - if (!existing) throw new Error('Barrier not found or access denied'); - - // Validate notes length - if (data.notes && data.notes.length > 255) { - throw new Error('Notes must be 255 characters or less'); - } - - const barrier = readinessBarriers.updateBarrier(id, data, currentUser.id, orgId); - - // Get patient name for audit - const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); - const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; - - const changes = {}; - if (data.status && data.status !== existing.status) changes.status = { from: existing.status, to: data.status }; - if (data.risk_level && data.risk_level !== existing.risk_level) changes.risk_level = { from: existing.risk_level, to: data.risk_level }; - - logAudit('update', 'ReadinessBarrier', id, patientName, JSON.stringify({ patient_id: existing.patient_id, changes }), currentUser.email, currentUser.role); - - return barrier; - }); - - ipcMain.handle('barrier:resolve', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - const existing = readinessBarriers.getBarrierById(id, orgId); - if (!existing) throw new Error('Barrier not found or access denied'); - - const barrier = readinessBarriers.updateBarrier(id, { status: 'resolved' }, currentUser.id, orgId); - - const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); - const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; - - logAudit('resolve', 'ReadinessBarrier', id, patientName, JSON.stringify({ patient_id: existing.patient_id, barrier_type: existing.barrier_type }), currentUser.email, currentUser.role); - - return barrier; - }); - - ipcMain.handle('barrier:delete', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - if (currentUser.role !== 'admin') { - throw new Error('Only administrators can delete barriers. Consider resolving the barrier instead.'); - } - - const existing = readinessBarriers.getBarrierById(id, orgId); - if (!existing) throw new Error('Barrier not found or access denied'); - - const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); - const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; - - readinessBarriers.deleteBarrier(id, orgId); - - logAudit('delete', 'ReadinessBarrier', id, patientName, JSON.stringify({ patient_id: existing.patient_id, barrier_type: existing.barrier_type }), currentUser.email, currentUser.role); - - return { success: true }; - }); - - ipcMain.handle('barrier:getByPatient', async (event, patientId, includeResolved = false) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return readinessBarriers.getBarriersByPatientId(patientId, getSessionOrgId(), includeResolved); - }); - - ipcMain.handle('barrier:getPatientSummary', async (event, patientId) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return readinessBarriers.getPatientBarrierSummary(patientId, getSessionOrgId()); - }); - - ipcMain.handle('barrier:getAllOpen', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return readinessBarriers.getAllOpenBarriers(getSessionOrgId()); - }); - - ipcMain.handle('barrier:getDashboard', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return readinessBarriers.getBarriersDashboard(getSessionOrgId()); - }); - - ipcMain.handle('barrier:getAuditHistory', async (event, patientId, startDate, endDate) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return readinessBarriers.getBarrierAuditHistory(getSessionOrgId(), patientId, startDate, endDate); - }); - - // ===== ADULT HEALTH HISTORY QUESTIONNAIRE (aHHQ) ===== - // NOTE: This feature is strictly NON-CLINICAL, NON-ALLOCATIVE, and designed for - // OPERATIONAL DOCUMENTATION purposes only. All operations are org-scoped. - - ipcMain.handle('ahhq:getStatuses', async () => ahhqService.AHHQ_STATUS); - ipcMain.handle('ahhq:getIssues', async () => ahhqService.AHHQ_ISSUES); - ipcMain.handle('ahhq:getOwningRoles', async () => ahhqService.AHHQ_OWNING_ROLES); - - ipcMain.handle('ahhq:create', async (event, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); - - const result = ahhqService.createAHHQ(data, currentUser.id, orgId); - logAudit('create', 'AdultHealthHistoryQuestionnaire', result.id, null, - JSON.stringify({ patient_id: data.patient_id, status: data.status }), - currentUser.email, currentUser.role); - return result; - }); - - ipcMain.handle('ahhq:getById', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getAHHQById(id, getSessionOrgId()); - }); - - ipcMain.handle('ahhq:getByPatient', async (event, patientId) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getAHHQByPatientId(patientId, getSessionOrgId()); - }); - - ipcMain.handle('ahhq:getPatientSummary', async (event, patientId) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getPatientAHHQSummary(patientId, getSessionOrgId()); - }); - - ipcMain.handle('ahhq:getAll', async (event, filters) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getAllAHHQs(getSessionOrgId(), filters); - }); - - ipcMain.handle('ahhq:getExpiring', async (event, days) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getExpiringAHHQs(getSessionOrgId(), days); - }); - - ipcMain.handle('ahhq:getExpired', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getExpiredAHHQs(getSessionOrgId()); - }); - - ipcMain.handle('ahhq:getIncomplete', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getIncompleteAHHQs(getSessionOrgId()); - }); - - ipcMain.handle('ahhq:update', async (event, id, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); - - const existing = ahhqService.getAHHQById(id, orgId); - if (!existing) throw new Error('aHHQ not found or access denied'); - - const result = ahhqService.updateAHHQ(id, data, currentUser.id, orgId); - - const changes = {}; - if (data.status !== undefined && data.status !== existing.status) changes.status = { from: existing.status, to: data.status }; - - logAudit('update', 'AdultHealthHistoryQuestionnaire', id, null, - JSON.stringify({ patient_id: existing.patient_id, changes }), - currentUser.email, currentUser.role); - return result; - }); - - ipcMain.handle('ahhq:markComplete', async (event, id, completedDate) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - const existing = ahhqService.getAHHQById(id, orgId); - if (!existing) throw new Error('aHHQ not found or access denied'); - - const result = ahhqService.markAHHQComplete(id, completedDate, currentUser.id, orgId); - logAudit('complete', 'AdultHealthHistoryQuestionnaire', id, null, - JSON.stringify({ patient_id: existing.patient_id, completed_date: completedDate || new Date().toISOString() }), - currentUser.email, currentUser.role); - return result; - }); - - ipcMain.handle('ahhq:markFollowUpRequired', async (event, id, issues) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - - const existing = ahhqService.getAHHQById(id, orgId); - if (!existing) throw new Error('aHHQ not found or access denied'); - - const result = ahhqService.markAHHQFollowUpRequired(id, issues, currentUser.id, orgId); - logAudit('follow_up_required', 'AdultHealthHistoryQuestionnaire', id, null, - JSON.stringify({ patient_id: existing.patient_id, issues }), - currentUser.email, currentUser.role); - return result; - }); - - ipcMain.handle('ahhq:delete', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - const orgId = getSessionOrgId(); - - const existing = ahhqService.getAHHQById(id, orgId); - if (!existing) throw new Error('aHHQ not found or access denied'); - - logAudit('delete', 'AdultHealthHistoryQuestionnaire', id, null, - JSON.stringify({ patient_id: existing.patient_id }), - currentUser.email, currentUser.role); - return ahhqService.deleteAHHQ(id, orgId); - }); - - ipcMain.handle('ahhq:getDashboard', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getAHHQDashboard(getSessionOrgId()); - }); - - ipcMain.handle('ahhq:getPatientsWithIssues', async (event, limit) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getPatientsWithAHHQIssues(getSessionOrgId(), limit); - }); - - ipcMain.handle('ahhq:getAuditHistory', async (event, patientId, startDate, endDate) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return ahhqService.getAHHQAuditHistory(getSessionOrgId(), patientId, startDate, endDate); - }); - - // ========================================================================= - // LAB RESULTS (Operational Documentation Only - Non-Clinical) - // ========================================================================= - // NOTE: This feature is strictly NON-CLINICAL and NON-ALLOCATIVE. - // Lab results are stored for DOCUMENTATION COMPLETENESS purposes only. - // The system does NOT interpret lab values, provide clinical recommendations, - // or make allocation decisions. Values are stored as strings. - - // Get common lab codes reference - ipcMain.handle('labs:getCodes', async () => labsService.COMMON_LAB_CODES); - ipcMain.handle('labs:getSources', async () => labsService.LAB_SOURCES); - - // Create a new lab result - ipcMain.handle('labs:create', async (event, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return labsService.createLabResult( - data, - orgId, - currentUser.id, - currentUser.email - ); - }); - - // Get lab result by ID - ipcMain.handle('labs:get', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getLabResultById(id, getSessionOrgId()); - }); - - // Get all lab results for a patient - ipcMain.handle('labs:getByPatient', async (event, patientId, options) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getLabResultsByPatient(patientId, getSessionOrgId(), options); - }); - - // Get latest lab for each test type for a patient - ipcMain.handle('labs:getLatestByPatient', async (event, patientId) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getLatestLabsByPatient(patientId, getSessionOrgId()); - }); - - // Update a lab result - ipcMain.handle('labs:update', async (event, id, data) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return labsService.updateLabResult( - id, - data, - orgId, - currentUser.id, - currentUser.email - ); - }); - - // Delete a lab result - ipcMain.handle('labs:delete', async (event, id) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - if (currentUser.role !== 'admin' && currentUser.role !== 'coordinator') { - throw new Error('Coordinator or admin access required to delete lab results'); - } - return labsService.deleteLabResult(id, getSessionOrgId(), currentUser.email); - }); - - // Get patient lab status (operational readiness signals only) - ipcMain.handle('labs:getPatientStatus', async (event, patientId) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getPatientLabStatus(patientId, getSessionOrgId()); - }); - - // Get labs dashboard metrics - ipcMain.handle('labs:getDashboard', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getLabsDashboard(getSessionOrgId()); - }); - - // Get required lab types for configuration - ipcMain.handle('labs:getRequiredTypes', async (event, organType) => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - return labsService.getRequiredLabTypes(getSessionOrgId(), organType); - }); - - // ===== ACCESS CONTROL WITH JUSTIFICATION ===== - ipcMain.handle('access:validateRequest', async (event, permission, justification) => { - if (!currentUser) throw new Error('Not authenticated'); - return accessControl.validateAccessRequest(currentUser.role, permission, justification); - }); - - ipcMain.handle('access:logJustifiedAccess', async (event, permission, entityType, entityId, justification) => { - if (!currentUser) throw new Error('Not authenticated'); - return accessControl.logAccessWithJustification( - db, currentUser.id, currentUser.email, currentUser.role, - permission, entityType, entityId, justification - ); - }); - - ipcMain.handle('access:getRoles', async () => { - return accessControl.getAllRoles(); - }); - - ipcMain.handle('access:getJustificationReasons', async () => { - return accessControl.JUSTIFICATION_REASONS; - }); - - // ===== DISASTER RECOVERY ===== - ipcMain.handle('recovery:createBackup', async (event, options) => { - if (!currentUser) throw new Error('Not authenticated'); - return await disasterRecovery.createBackup({ - ...options, - createdBy: currentUser.email, - }); - }); - - ipcMain.handle('recovery:listBackups', async () => { - return disasterRecovery.listBackups(); - }); - - ipcMain.handle('recovery:verifyBackup', async (event, backupId) => { - return disasterRecovery.verifyBackup(backupId); - }); - - ipcMain.handle('recovery:restoreBackup', async (event, backupId) => { - if (!currentUser || currentUser.role !== 'admin') { - throw new Error('Admin access required for restore'); - } - return await disasterRecovery.restoreFromBackup(backupId, { - restoredBy: currentUser.email, - }); - }); - - ipcMain.handle('recovery:getStatus', async () => { - return disasterRecovery.getRecoveryStatus(); - }); - - // ===== COMPLIANCE VIEW (READ-ONLY FOR REGULATORS) ===== - ipcMain.handle('compliance:getSummary', async () => { - if (!currentUser) throw new Error('Not authenticated'); - complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_summary', 'Viewed compliance summary'); - return complianceView.getComplianceSummary(); - }); - - ipcMain.handle('compliance:getAuditTrail', async (event, options) => { - if (!currentUser) throw new Error('Not authenticated'); - complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_audit', 'Viewed audit trail'); - return complianceView.getAuditTrailForCompliance(options); - }); - - ipcMain.handle('compliance:getDataCompleteness', async () => { - if (!currentUser) throw new Error('Not authenticated'); - return complianceView.getDataCompletenessReport(); - }); - - ipcMain.handle('compliance:getValidationReport', async () => { - if (!currentUser) throw new Error('Not authenticated'); - complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_validation', 'Viewed validation report'); - return complianceView.generateValidationReport(); - }); - - ipcMain.handle('compliance:getAccessLogs', async (event, options) => { - if (!currentUser) throw new Error('Not authenticated'); - return complianceView.getAccessLogReport(options); - }); - - // ===== OFFLINE RECONCILIATION ===== - ipcMain.handle('reconciliation:getStatus', async () => { - return offlineReconciliation.getReconciliationStatus(); - }); - - ipcMain.handle('reconciliation:getPendingChanges', async () => { - return offlineReconciliation.getPendingChanges(); - }); - - ipcMain.handle('reconciliation:reconcile', async (event, strategy) => { - if (!currentUser || currentUser.role !== 'admin') { - throw new Error('Admin access required'); - } - return await offlineReconciliation.reconcilePendingChanges(strategy); - }); - - ipcMain.handle('reconciliation:setMode', async (event, mode) => { - if (!currentUser || currentUser.role !== 'admin') { - throw new Error('Admin access required'); - } - return offlineReconciliation.setOperationMode(mode); - }); - - ipcMain.handle('reconciliation:getMode', async () => { - return offlineReconciliation.getOperationMode(); - }); - - // ===== LICENSE MANAGEMENT (Additional Handlers) ===== - // Note: license:getInfo, license:activate, license:checkFeature are defined earlier - - // Renew maintenance - ipcMain.handle('license:renewMaintenance', async (event, renewalKey, years) => { - if (!currentUser) throw new Error('Not authenticated'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - const result = await licenseManager.renewMaintenance(renewalKey, years); - - logAudit('maintenance_renewed', 'License', null, null, - `Maintenance renewed for ${years} year(s)`, currentUser.email, currentUser.role); - - return result; - }); - - // Check if license is valid - ipcMain.handle('license:isValid', async () => { - return licenseManager.isLicenseValid(); - }); - - // Get current license tier - ipcMain.handle('license:getTier', async () => { - return licenseManager.getCurrentTier(); - }); - - // Get tier limits - ipcMain.handle('license:getLimits', async () => { - const tier = licenseManager.getCurrentTier(); - return licenseManager.getTierLimits(tier); - }); - - // Check limit - ipcMain.handle('license:checkLimit', async (event, limitType, currentCount) => { - return featureGate.canWithinLimit(limitType, currentCount); - }); - - // Get application state - ipcMain.handle('license:getAppState', async () => { - return featureGate.checkApplicationState(); - }); - - // Get payment options - ipcMain.handle('license:getPaymentOptions', async () => { - return licenseManager.getAllPaymentOptions(); - }); - - // Get payment info for specific tier - ipcMain.handle('license:getPaymentInfo', async (event, tier) => { - return licenseManager.getPaymentInfo(tier); - }); - - // Get organization info - ipcMain.handle('license:getOrganization', async () => { - return licenseManager.getOrganizationInfo(); - }); - - // Update organization info - ipcMain.handle('license:updateOrganization', async (event, updates) => { - if (!currentUser) throw new Error('Not authenticated'); - if (currentUser.role !== 'admin') throw new Error('Admin access required'); - - return licenseManager.updateOrganizationInfo(updates); - }); - - // Get maintenance status - ipcMain.handle('license:getMaintenanceStatus', async () => { - return licenseManager.getMaintenanceStatus(); - }); - - // Get license audit history - ipcMain.handle('license:getAuditHistory', async (event, limit) => { - if (!currentUser) throw new Error('Not authenticated'); - return licenseManager.getLicenseAuditHistory(limit); - }); - - // Check if evaluation build - ipcMain.handle('license:isEvaluationBuild', async () => { - return licenseManager.isEvaluationBuild(); - }); - - // Get evaluation status - ipcMain.handle('license:getEvaluationStatus', async () => { - return { - isEvaluation: licenseManager.isEvaluationMode(), - daysRemaining: licenseManager.getEvaluationDaysRemaining(), - expired: licenseManager.isEvaluationExpired(), - inGracePeriod: licenseManager.isInEvaluationGracePeriod(), - }; - }); - - // Get all features and their status - ipcMain.handle('license:getAllFeatures', async () => { - const tier = licenseManager.getCurrentTier(); - const allFeatures = Object.values(FEATURES); - - return allFeatures.map(feature => ({ - feature, - ...featureGate.canAccessFeature(feature), - })); - }); - - // Check full access (combined checks) - ipcMain.handle('license:checkFullAccess', async (event, options) => { - return featureGate.checkFullAccess(options); - }); - - // ===== FILE OPERATIONS ===== - ipcMain.handle('file:exportCSV', async (event, data, filename) => { - // Check feature access for data export - const exportCheck = featureGate.canAccessFeature(FEATURES.DATA_EXPORT); - if (!exportCheck.allowed) { - throw new Error('Data export is not available in your current license tier. Please upgrade to export data.'); - } - - const { dialog } = require('electron'); - const fs = require('fs'); - - const { filePath } = await dialog.showSaveDialog({ - title: 'Export CSV', - defaultPath: filename, - filters: [{ name: 'CSV Files', extensions: ['csv'] }] - }); - - if (filePath) { - // Convert data to CSV - if (data.length === 0) { - fs.writeFileSync(filePath, ''); - } else { - const headers = Object.keys(data[0]).join(','); - const rows = data.map(row => - Object.values(row).map(v => - typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : v - ).join(',') - ); - fs.writeFileSync(filePath, [headers, ...rows].join('\n')); - } - - logAudit('export', 'System', null, null, `CSV exported: ${filename}`, currentUser.email, currentUser.role); - return { success: true, path: filePath }; - } - - return { success: false }; - }); - - ipcMain.handle('file:backupDatabase', async (event, targetPath) => { - const { backupDatabase } = require('../database/init.cjs'); - await backupDatabase(targetPath); - return { success: true }; - }); - - // ========================================================================= - // TRANSPLANT CLOCK (Operational Activity Rhythm) - // ========================================================================= - // The Transplant Clock provides real-time operational awareness for transplant - // coordination teams. It acts as a visual heartbeat of the program. - // 100% computed locally from the encrypted SQLite database. - // No cloud, API, or AI inference required. - - const transplantClock = require('../services/transplantClock.cjs'); - - ipcMain.handle('clock:getData', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getTransplantClockData(orgId); - }); - - ipcMain.handle('clock:getTimeSinceLastUpdate', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getTimeSinceLastUpdate(orgId); - }); - - ipcMain.handle('clock:getAverageResolutionTime', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getAverageResolutionTime(orgId); - }); - - ipcMain.handle('clock:getNextExpiration', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getNextExpiration(orgId); - }); - - ipcMain.handle('clock:getTaskCounts', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getTaskCounts(orgId); - }); - - ipcMain.handle('clock:getCoordinatorLoad', async () => { - if (!validateSession()) throw new Error('Session expired. Please log in again.'); - const orgId = getSessionOrgId(); - return transplantClock.getCoordinatorLoad(orgId); - }); - - // ========================================================================= - // HELPER FUNCTIONS - // ========================================================================= - - /** - * Get entity by ID (legacy - no org check) - * @deprecated Use getEntityByIdAndOrg for org-isolated queries - */ - function getEntityById(tableName, id) { - const row = db.prepare(`SELECT * FROM ${tableName} WHERE id = ?`).get(id); - return row ? parseJsonFields(row) : null; - } - - /** - * Get entity by ID with org isolation - * CRITICAL: This ensures users can only access data from their organization - * @param {string} tableName - The table name - * @param {string} id - The entity ID - * @param {string} orgId - The organization ID - * @returns {Object|null} The entity or null if not found/not in org - */ - function getEntityByIdAndOrg(tableName, id, orgId) { - if (!orgId) { - throw new Error('Organization context required for data access'); - } - const row = db.prepare(`SELECT * FROM ${tableName} WHERE id = ? AND org_id = ?`).get(id, orgId); - return row ? parseJsonFields(row) : null; - } - - /** - * List entities with org isolation - * @param {string} tableName - The table name - * @param {string} orgId - The organization ID - * @param {string} orderBy - Column to order by (with optional - prefix for DESC) - * @param {number} limit - Max rows to return - * @returns {Array} List of entities - */ - function listEntitiesByOrg(tableName, orgId, orderBy, limit) { - if (!orgId) { - throw new Error('Organization context required for data access'); - } - - let query = `SELECT * FROM ${tableName} WHERE org_id = ?`; - - // Handle ordering with SQL injection prevention - if (orderBy) { - const desc = orderBy.startsWith('-'); - const field = desc ? orderBy.substring(1) : orderBy; - - // Validate column name against whitelist - if (!isValidOrderColumn(tableName, field)) { - throw new Error(`Invalid sort field: ${field}`); - } - - query += ` ORDER BY ${field} ${desc ? 'DESC' : 'ASC'}`; - } else { - // Use created_at (new schema) or created_date (old schema) - query += ' ORDER BY created_at DESC'; - } - - // Handle limit with bounds validation - if (limit) { - const parsedLimit = parseInt(limit, 10); - if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) { - throw new Error('Invalid limit value. Must be between 1 and 10000.'); - } - query += ` LIMIT ${parsedLimit}`; - } - - const rows = db.prepare(query).all(orgId); - return rows.map(row => parseJsonFields(row)); - } - - function parseJsonFields(row) { - if (!row) return row; - const parsed = { ...row }; - for (const field of jsonFields) { - if (parsed[field] && typeof parsed[field] === 'string') { - try { - parsed[field] = JSON.parse(parsed[field]); - } catch (e) { - // Keep as string if parsing fails - } - } - } - return parsed; - } - - /** - * Log audit event with org isolation - * All audit logs are scoped to the current organization - */ - function logAudit(action, entityType, entityId, patientName, details, userEmail, userRole) { - const id = uuidv4(); - // Get org_id from session if available, otherwise use 'SYSTEM' - const orgId = currentUser?.org_id || 'SYSTEM'; - const now = new Date().toISOString(); - - db.prepare(` - INSERT INTO audit_logs (id, org_id, action, entity_type, entity_id, patient_name, details, user_email, user_role, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, orgId, action, entityType, entityId, patientName, details, userEmail, userRole, now); - } + authHandlers.register(); + entityHandlers.register(); + adminHandlers.register(); + licenseHandlers.register(); + barrierHandlers.register(); + ahhqHandlers.register(); + labsHandlers.register(); + clinicalHandlers.register(); + operationsHandlers.register(); } module.exports = { setupIPCHandlers }; diff --git a/electron/ipc/handlers/admin.cjs b/electron/ipc/handlers/admin.cjs new file mode 100644 index 0000000..038696a --- /dev/null +++ b/electron/ipc/handlers/admin.cjs @@ -0,0 +1,143 @@ +/** + * TransTrack - Admin IPC Handlers + * Handles: app:*, organization:*, settings:*, encryption:* + */ + +const { ipcMain } = require('electron'); +const { v4: uuidv4 } = require('uuid'); +const { + getDatabase, + isEncryptionEnabled, + verifyDatabaseIntegrity, + getEncryptionStatus, + getOrgLicense, + getPatientCount, + getUserCount, +} = require('../../database/init.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + // ===== APP INFO ===== + ipcMain.handle('app:getInfo', () => ({ + name: 'TransTrack', + version: '1.0.0', + compliance: ['HIPAA', 'FDA 21 CFR Part 11', 'AATB'], + encryptionEnabled: isEncryptionEnabled(), + })); + + ipcMain.handle('app:getVersion', () => '1.0.0'); + + // ===== ENCRYPTION STATUS ===== + ipcMain.handle('encryption:getStatus', async () => getEncryptionStatus()); + + ipcMain.handle('encryption:verifyIntegrity', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + + const result = verifyDatabaseIntegrity(); + shared.logAudit('encryption_verify', 'System', null, null, + `Database integrity check: ${result.valid ? 'PASSED' : 'FAILED'}`, + currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('encryption:isEnabled', async () => isEncryptionEnabled()); + + // ===== ORGANIZATION MANAGEMENT ===== + ipcMain.handle('organization:getCurrent', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const orgId = shared.getSessionOrgId(); + const org = db.prepare('SELECT * FROM organizations WHERE id = ?').get(orgId); + if (!org) throw new Error('Organization not found'); + + const license = getOrgLicense(orgId); + const patientCount = getPatientCount(orgId); + const userCount = getUserCount(orgId); + + return { + ...org, + license: license ? { + tier: license.tier, + maxPatients: license.max_patients, + maxUsers: license.max_users, + expiresAt: license.license_expires_at, + maintenanceExpiresAt: license.maintenance_expires_at, + } : null, + usage: { patients: patientCount, users: userCount }, + }; + }); + + ipcMain.handle('organization:update', async (event, updates) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + + const orgId = shared.getSessionOrgId(); + const now = new Date().toISOString(); + const allowedFields = ['name', 'address', 'phone', 'email', 'settings']; + const safeUpdates = {}; + + for (const field of allowedFields) { + if (updates[field] !== undefined) safeUpdates[field] = updates[field]; + } + if (Object.keys(safeUpdates).length === 0) throw new Error('No valid fields to update'); + + if (safeUpdates.settings && typeof safeUpdates.settings === 'object') { + safeUpdates.settings = JSON.stringify(safeUpdates.settings); + } + + const setClause = Object.keys(safeUpdates).map(k => `${k} = ?`).join(', '); + const values = [...Object.values(safeUpdates), now, orgId]; + db.prepare(`UPDATE organizations SET ${setClause}, updated_at = ? WHERE id = ?`).run(...values); + + shared.logAudit('update', 'Organization', orgId, null, 'Organization settings updated', currentUser.email, currentUser.role); + return { success: true }; + }); + + // ===== SETTINGS (Org-Scoped) ===== + ipcMain.handle('settings:get', async (event, key) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const orgId = shared.getSessionOrgId(); + const setting = db.prepare('SELECT value FROM settings WHERE org_id = ? AND key = ?').get(orgId, key); + if (!setting) return null; + try { return JSON.parse(setting.value); } catch { return setting.value; } + }); + + ipcMain.handle('settings:set', async (event, key, value) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + + const orgId = shared.getSessionOrgId(); + const now = new Date().toISOString(); + const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value); + const existing = db.prepare('SELECT id FROM settings WHERE org_id = ? AND key = ?').get(orgId, key); + + if (existing) { + db.prepare('UPDATE settings SET value = ?, updated_at = ? WHERE id = ?').run(valueStr, now, existing.id); + } else { + db.prepare('INSERT INTO settings (id, org_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)').run( + uuidv4(), orgId, key, valueStr, now + ); + } + + shared.logAudit('settings_update', 'Settings', key, null, `Setting '${key}' updated`, currentUser.email, currentUser.role); + return { success: true }; + }); + + ipcMain.handle('settings:getAll', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const orgId = shared.getSessionOrgId(); + const settings = db.prepare('SELECT key, value FROM settings WHERE org_id = ?').all(orgId); + const result = {}; + for (const setting of settings) { + try { result[setting.key] = JSON.parse(setting.value); } catch { result[setting.key] = setting.value; } + } + return result; + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/ahhq.cjs b/electron/ipc/handlers/ahhq.cjs new file mode 100644 index 0000000..82ceb14 --- /dev/null +++ b/electron/ipc/handlers/ahhq.cjs @@ -0,0 +1,140 @@ +/** + * TransTrack - Adult Health History Questionnaire IPC Handlers + * Handles: ahhq:* + * + * Strictly NON-CLINICAL, NON-ALLOCATIVE — operational documentation only. + */ + +const { ipcMain } = require('electron'); +const ahhqService = require('../../services/ahhqService.cjs'); +const shared = require('../shared.cjs'); + +function register() { + ipcMain.handle('ahhq:getStatuses', async () => ahhqService.AHHQ_STATUS); + ipcMain.handle('ahhq:getIssues', async () => ahhqService.AHHQ_ISSUES); + ipcMain.handle('ahhq:getOwningRoles', async () => ahhqService.AHHQ_OWNING_ROLES); + + ipcMain.handle('ahhq:create', async (event, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); + + const result = ahhqService.createAHHQ(data, currentUser.id, orgId); + shared.logAudit('create', 'AdultHealthHistoryQuestionnaire', result.id, null, + JSON.stringify({ patient_id: data.patient_id, status: data.status }), currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('ahhq:getById', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getAHHQById(id, shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:getByPatient', async (event, patientId) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getAHHQByPatientId(patientId, shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:getPatientSummary', async (event, patientId) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getPatientAHHQSummary(patientId, shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:getAll', async (event, filters) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getAllAHHQs(shared.getSessionOrgId(), filters); + }); + + ipcMain.handle('ahhq:getExpiring', async (event, days) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getExpiringAHHQs(shared.getSessionOrgId(), days); + }); + + ipcMain.handle('ahhq:getExpired', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getExpiredAHHQs(shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:getIncomplete', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getIncompleteAHHQs(shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:update', async (event, id, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); + + const existing = ahhqService.getAHHQById(id, orgId); + if (!existing) throw new Error('aHHQ not found or access denied'); + + const result = ahhqService.updateAHHQ(id, data, currentUser.id, orgId); + const changes = {}; + if (data.status !== undefined && data.status !== existing.status) changes.status = { from: existing.status, to: data.status }; + + shared.logAudit('update', 'AdultHealthHistoryQuestionnaire', id, null, + JSON.stringify({ patient_id: existing.patient_id, changes }), currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('ahhq:markComplete', async (event, id, completedDate) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + const existing = ahhqService.getAHHQById(id, orgId); + if (!existing) throw new Error('aHHQ not found or access denied'); + + const result = ahhqService.markAHHQComplete(id, completedDate, currentUser.id, orgId); + shared.logAudit('complete', 'AdultHealthHistoryQuestionnaire', id, null, + JSON.stringify({ patient_id: existing.patient_id, completed_date: completedDate || new Date().toISOString() }), currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('ahhq:markFollowUpRequired', async (event, id, issues) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + const existing = ahhqService.getAHHQById(id, orgId); + if (!existing) throw new Error('aHHQ not found or access denied'); + + const result = ahhqService.markAHHQFollowUpRequired(id, issues, currentUser.id, orgId); + shared.logAudit('follow_up_required', 'AdultHealthHistoryQuestionnaire', id, null, + JSON.stringify({ patient_id: existing.patient_id, issues }), currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('ahhq:delete', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + const orgId = shared.getSessionOrgId(); + + const existing = ahhqService.getAHHQById(id, orgId); + if (!existing) throw new Error('aHHQ not found or access denied'); + + shared.logAudit('delete', 'AdultHealthHistoryQuestionnaire', id, null, + JSON.stringify({ patient_id: existing.patient_id }), currentUser.email, currentUser.role); + return ahhqService.deleteAHHQ(id, orgId); + }); + + ipcMain.handle('ahhq:getDashboard', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getAHHQDashboard(shared.getSessionOrgId()); + }); + + ipcMain.handle('ahhq:getPatientsWithIssues', async (event, limit) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getPatientsWithAHHQIssues(shared.getSessionOrgId(), limit); + }); + + ipcMain.handle('ahhq:getAuditHistory', async (event, patientId, startDate, endDate) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return ahhqService.getAHHQAuditHistory(shared.getSessionOrgId(), patientId, startDate, endDate); + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/auth.cjs b/electron/ipc/handlers/auth.cjs new file mode 100644 index 0000000..17da1a5 --- /dev/null +++ b/electron/ipc/handlers/auth.cjs @@ -0,0 +1,275 @@ +/** + * TransTrack - Authentication IPC Handlers + * Handles: auth:login, auth:logout, auth:me, auth:isAuthenticated, + * auth:register, auth:changePassword, auth:createUser, + * auth:listUsers, auth:updateUser, auth:deleteUser + */ + +const { ipcMain } = require('electron'); +const { v4: uuidv4 } = require('uuid'); +const bcrypt = require('bcryptjs'); +const { + getDatabase, + getDefaultOrganization, + getOrgLicense, + getUserCount, +} = require('../../database/init.cjs'); +const { LICENSE_TIER, checkDataLimit } = require('../../license/tiers.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + ipcMain.handle('auth:login', async (event, { email, password }) => { + try { + const lockoutStatus = shared.checkAccountLockout(email); + if (lockoutStatus.locked) { + shared.logAudit('login_blocked', 'User', null, null, `Login blocked: account locked for ${lockoutStatus.remainingTime} more minutes`, email, null); + throw new Error(`Account temporarily locked due to too many failed attempts. Try again in ${lockoutStatus.remainingTime} minutes.`); + } + + const user = db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email); + if (!user) { + shared.recordFailedLogin(email); + shared.logAudit('login_failed', 'User', null, null, 'Login failed: user not found', email, null); + throw new Error('Invalid credentials'); + } + + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + shared.recordFailedLogin(email); + shared.logAudit('login_failed', 'User', null, null, 'Login failed: invalid password', email, null); + throw new Error('Invalid credentials'); + } + + if (!user.org_id) { + const defaultOrg = getDefaultOrganization(); + if (defaultOrg) { + db.prepare('UPDATE users SET org_id = ? WHERE id = ?').run(defaultOrg.id, user.id); + user.org_id = defaultOrg.id; + } else { + throw new Error('No organization configured. Please contact administrator.'); + } + } + + const org = db.prepare('SELECT * FROM organizations WHERE id = ?').get(user.org_id); + if (!org || org.status !== 'ACTIVE') { + throw new Error('Your organization is not active. Please contact administrator.'); + } + + const license = getOrgLicense(user.org_id); + const licenseTier = license?.tier || LICENSE_TIER.EVALUATION; + + shared.clearFailedLogins(email); + + const sessionId = uuidv4(); + const expiresAtDate = new Date(Date.now() + shared.SESSION_DURATION_MS); + db.prepare('INSERT INTO sessions (id, user_id, org_id, expires_at) VALUES (?, ?, ?, ?)').run( + sessionId, user.id, user.org_id, expiresAtDate.toISOString() + ); + + db.prepare("UPDATE users SET last_login = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(user.id); + + const currentUser = { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + org_id: user.org_id, + org_name: org.name, + license_tier: licenseTier, + }; + + shared.setSessionState(sessionId, currentUser, expiresAtDate.getTime()); + shared.logAudit('login', 'User', user.id, null, 'User logged in successfully', user.email, user.role); + + return { success: true, user: currentUser }; + } catch (error) { + const safeMessage = + error.message.includes('locked') || + error.message === 'Invalid credentials' || + error.message.includes('organization') + ? error.message + : 'Authentication failed'; + throw new Error(safeMessage); + } + }); + + ipcMain.handle('auth:logout', async () => { + const { currentSession, currentUser } = shared.getSessionState(); + if (currentSession) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(currentSession); + shared.logAudit('logout', 'User', currentUser?.id, null, 'User logged out', currentUser?.email, currentUser?.role); + } + shared.clearSession(); + return { success: true }; + }); + + ipcMain.handle('auth:me', async () => { + if (!shared.validateSession()) { + shared.clearSession(); + throw new Error('Session expired. Please log in again.'); + } + return shared.getSessionState().currentUser; + }); + + ipcMain.handle('auth:isAuthenticated', async () => shared.validateSession()); + + ipcMain.handle('auth:register', async (event, userData) => { + let defaultOrg = getDefaultOrganization(); + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get(); + const { currentUser } = shared.getSessionState(); + + if (userCount.count > 0 && (!currentUser || currentUser.role !== 'admin')) { + throw new Error('Registration not allowed. Please contact administrator.'); + } + + if (!defaultOrg) { + const { createDefaultOrganization } = require('../../database/init.cjs'); + defaultOrg = createDefaultOrganization(); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userData.email)) { + throw new Error('Invalid email format'); + } + + const passwordValidation = shared.validatePasswordStrength(userData.password); + if (!passwordValidation.valid) { + throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); + } + + if (!userData.full_name || userData.full_name.trim().length < 2) { + throw new Error('Full name must be at least 2 characters'); + } + + const hashedPassword = await bcrypt.hash(userData.password, 12); + const userId = uuidv4(); + const now = new Date().toISOString(); + const orgId = currentUser?.org_id || defaultOrg.id; + + db.prepare( + 'INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(userId, orgId, userData.email, hashedPassword, userData.full_name.trim(), userData.role || 'admin', 1, now, now); + + shared.logAudit('create', 'User', userId, null, 'User registered', userData.email, userData.role || 'admin'); + return { success: true, id: userId }; + }); + + ipcMain.handle('auth:changePassword', async (event, { currentPassword, newPassword }) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + + const passwordValidation = shared.validatePasswordStrength(newPassword); + if (!passwordValidation.valid) { + throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); + } + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(currentUser.id); + const isValid = await bcrypt.compare(currentPassword, user.password_hash); + if (!isValid) throw new Error('Current password is incorrect'); + + const hashedPassword = await bcrypt.hash(newPassword, 12); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hashedPassword, currentUser.id); + + shared.logAudit('update', 'User', currentUser.id, null, 'Password changed', currentUser.email, currentUser.role); + return { success: true }; + }); + + ipcMain.handle('auth:createUser', async (event, userData) => { + const { currentUser } = shared.getSessionState(); + if (!shared.validateSession() || currentUser.role !== 'admin') { + throw new Error('Unauthorized: Admin access required'); + } + + const orgId = shared.getSessionOrgId(); + const userCount = getUserCount(orgId); + const tier = shared.getSessionTier(); + const limitCheck = checkDataLimit(tier, 'maxUsers', userCount); + if (!limitCheck.allowed) { + throw new Error(`User limit reached (${limitCheck.limit}). Please upgrade your license to add more users.`); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userData.email)) { + throw new Error('Invalid email format'); + } + + const existingUser = db.prepare('SELECT id FROM users WHERE org_id = ? AND email = ?').get(orgId, userData.email); + if (existingUser) { + throw new Error('A user with this email already exists in your organization.'); + } + + const passwordValidation = shared.validatePasswordStrength(userData.password); + if (!passwordValidation.valid) { + throw new Error(`Password requirements not met: ${passwordValidation.errors.join(', ')}`); + } + + const hashedPassword = await bcrypt.hash(userData.password, 12); + const userId = uuidv4(); + const now = new Date().toISOString(); + + db.prepare( + 'INSERT INTO users (id, org_id, email, password_hash, full_name, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(userId, orgId, userData.email, hashedPassword, userData.full_name, userData.role || 'user', 1, now, now); + + shared.logAudit('create', 'User', userId, null, 'User created', currentUser.email, currentUser.role); + return { success: true, id: userId }; + }); + + ipcMain.handle('auth:listUsers', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const orgId = shared.getSessionOrgId(); + return db.prepare('SELECT id, email, full_name, role, is_active, created_at, last_login FROM users WHERE org_id = ? ORDER BY created_at DESC').all(orgId); + }); + + ipcMain.handle('auth:updateUser', async (event, id, userData) => { + const { currentUser } = shared.getSessionState(); + if (!shared.validateSession() || (currentUser.role !== 'admin' && currentUser.id !== id)) { + throw new Error('Unauthorized'); + } + + const updates = []; + const values = []; + + if (userData.full_name !== undefined) { + updates.push('full_name = ?'); + values.push(userData.full_name); + } + if (userData.role !== undefined && currentUser.role === 'admin') { + const validRoles = ['admin', 'coordinator', 'physician', 'user', 'viewer', 'regulator']; + if (!validRoles.includes(userData.role)) throw new Error('Invalid role specified'); + updates.push('role = ?'); + values.push(userData.role); + } + if (userData.is_active !== undefined && currentUser.role === 'admin') { + updates.push('is_active = ?'); + values.push(userData.is_active ? 1 : 0); + if (!userData.is_active) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + shared.logAudit('session_invalidated', 'User', id, null, 'User sessions invalidated due to account deactivation', currentUser.email, currentUser.role); + } + } + + if (updates.length > 0) { + updates.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); + shared.logAudit('update', 'User', id, null, 'User updated', currentUser.email, currentUser.role); + } + return { success: true }; + }); + + ipcMain.handle('auth:deleteUser', async (event, id) => { + const { currentUser } = shared.getSessionState(); + if (!shared.validateSession() || currentUser.role !== 'admin') { + throw new Error('Unauthorized: Admin access required'); + } + if (id === currentUser.id) throw new Error('Cannot delete your own account'); + + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); + shared.logAudit('delete', 'User', id, null, 'User deleted', currentUser.email, currentUser.role); + return { success: true }; + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/barriers.cjs b/electron/ipc/handlers/barriers.cjs new file mode 100644 index 0000000..01a0ee8 --- /dev/null +++ b/electron/ipc/handlers/barriers.cjs @@ -0,0 +1,126 @@ +/** + * TransTrack - Readiness Barriers IPC Handlers + * Handles: barrier:* + * + * Strictly NON-CLINICAL, NON-ALLOCATIVE — designed for + * operational workflow visibility only. + */ + +const { ipcMain } = require('electron'); +const { getDatabase } = require('../../database/init.cjs'); +const readinessBarriers = require('../../services/readinessBarriers.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + ipcMain.handle('barrier:getTypes', async () => readinessBarriers.BARRIER_TYPES); + ipcMain.handle('barrier:getStatuses', async () => readinessBarriers.BARRIER_STATUS); + ipcMain.handle('barrier:getRiskLevels', async () => readinessBarriers.BARRIER_RISK_LEVEL); + ipcMain.handle('barrier:getOwningRoles', async () => readinessBarriers.OWNING_ROLES); + + ipcMain.handle('barrier:create', async (event, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + if (!data.patient_id) throw new Error('Patient ID is required'); + if (!data.barrier_type) throw new Error('Barrier type is required'); + if (!data.owning_role) throw new Error('Owning role is required'); + if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); + + const barrier = readinessBarriers.createBarrier(data, currentUser.id, orgId); + const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(data.patient_id, orgId); + const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; + + shared.logAudit('create', 'ReadinessBarrier', barrier.id, patientName, + JSON.stringify({ patient_id: data.patient_id, barrier_type: data.barrier_type, status: barrier.status, risk_level: barrier.risk_level }), + currentUser.email, currentUser.role); + return barrier; + }); + + ipcMain.handle('barrier:update', async (event, id, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + const existing = readinessBarriers.getBarrierById(id, orgId); + if (!existing) throw new Error('Barrier not found or access denied'); + if (data.notes && data.notes.length > 255) throw new Error('Notes must be 255 characters or less'); + + const barrier = readinessBarriers.updateBarrier(id, data, currentUser.id, orgId); + const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); + const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; + + const changes = {}; + if (data.status && data.status !== existing.status) changes.status = { from: existing.status, to: data.status }; + if (data.risk_level && data.risk_level !== existing.risk_level) changes.risk_level = { from: existing.risk_level, to: data.risk_level }; + + shared.logAudit('update', 'ReadinessBarrier', id, patientName, + JSON.stringify({ patient_id: existing.patient_id, changes }), currentUser.email, currentUser.role); + return barrier; + }); + + ipcMain.handle('barrier:resolve', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + const existing = readinessBarriers.getBarrierById(id, orgId); + if (!existing) throw new Error('Barrier not found or access denied'); + + const barrier = readinessBarriers.updateBarrier(id, { status: 'resolved' }, currentUser.id, orgId); + const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); + const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; + + shared.logAudit('resolve', 'ReadinessBarrier', id, patientName, + JSON.stringify({ patient_id: existing.patient_id, barrier_type: existing.barrier_type }), currentUser.email, currentUser.role); + return barrier; + }); + + ipcMain.handle('barrier:delete', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const orgId = shared.getSessionOrgId(); + + if (currentUser.role !== 'admin') throw new Error('Only administrators can delete barriers. Consider resolving the barrier instead.'); + + const existing = readinessBarriers.getBarrierById(id, orgId); + if (!existing) throw new Error('Barrier not found or access denied'); + + const patient = db.prepare('SELECT first_name, last_name FROM patients WHERE id = ? AND org_id = ?').get(existing.patient_id, orgId); + const patientName = patient ? `${patient.first_name} ${patient.last_name}` : null; + + readinessBarriers.deleteBarrier(id, orgId); + shared.logAudit('delete', 'ReadinessBarrier', id, patientName, + JSON.stringify({ patient_id: existing.patient_id, barrier_type: existing.barrier_type }), currentUser.email, currentUser.role); + return { success: true }; + }); + + ipcMain.handle('barrier:getByPatient', async (event, patientId, includeResolved = false) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return readinessBarriers.getBarriersByPatientId(patientId, shared.getSessionOrgId(), includeResolved); + }); + + ipcMain.handle('barrier:getPatientSummary', async (event, patientId) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return readinessBarriers.getPatientBarrierSummary(patientId, shared.getSessionOrgId()); + }); + + ipcMain.handle('barrier:getAllOpen', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return readinessBarriers.getAllOpenBarriers(shared.getSessionOrgId()); + }); + + ipcMain.handle('barrier:getDashboard', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return readinessBarriers.getBarriersDashboard(shared.getSessionOrgId()); + }); + + ipcMain.handle('barrier:getAuditHistory', async (event, patientId, startDate, endDate) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return readinessBarriers.getBarrierAuditHistory(shared.getSessionOrgId(), patientId, startDate, endDate); + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/clinical.cjs b/electron/ipc/handlers/clinical.cjs new file mode 100644 index 0000000..46ad291 --- /dev/null +++ b/electron/ipc/handlers/clinical.cjs @@ -0,0 +1,69 @@ +/** + * TransTrack - Clinical Operations IPC Handlers + * Handles: risk:*, clock:*, function:invoke + */ + +const { ipcMain } = require('electron'); +const { getDatabase } = require('../../database/init.cjs'); +const riskEngine = require('../../services/riskEngine.cjs'); +const transplantClock = require('../../services/transplantClock.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + // ===== OPERATIONAL RISK INTELLIGENCE ===== + ipcMain.handle('risk:getDashboard', async () => riskEngine.getRiskDashboard()); + + ipcMain.handle('risk:getFullReport', async () => riskEngine.generateOperationalRiskReport()); + + ipcMain.handle('risk:assessPatient', async (event, patientId) => { + const patient = db.prepare('SELECT * FROM patients WHERE id = ?').get(patientId); + if (!patient) throw new Error('Patient not found'); + return riskEngine.assessPatientOperationalRisk(patient); + }); + + // ===== TRANSPLANT CLOCK ===== + ipcMain.handle('clock:getData', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getTransplantClockData(shared.getSessionOrgId()); + }); + + ipcMain.handle('clock:getTimeSinceLastUpdate', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getTimeSinceLastUpdate(shared.getSessionOrgId()); + }); + + ipcMain.handle('clock:getAverageResolutionTime', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getAverageResolutionTime(shared.getSessionOrgId()); + }); + + ipcMain.handle('clock:getNextExpiration', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getNextExpiration(shared.getSessionOrgId()); + }); + + ipcMain.handle('clock:getTaskCounts', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getTaskCounts(shared.getSessionOrgId()); + }); + + ipcMain.handle('clock:getCoordinatorLoad', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return transplantClock.getCoordinatorLoad(shared.getSessionOrgId()); + }); + + // ===== BUSINESS FUNCTIONS ===== + ipcMain.handle('function:invoke', async (event, functionName, params) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + + const functions = require('../../functions/index.cjs'); + if (!functions[functionName]) throw new Error(`Unknown function: ${functionName}`); + + return await functions[functionName](params, { db, currentUser, logAudit: shared.logAudit }); + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/entities.cjs b/electron/ipc/handlers/entities.cjs new file mode 100644 index 0000000..b73e1d3 --- /dev/null +++ b/electron/ipc/handlers/entities.cjs @@ -0,0 +1,197 @@ +/** + * TransTrack - Entity CRUD IPC Handlers + * Handles: entity:create, entity:get, entity:update, entity:delete, + * entity:list, entity:filter + */ + +const { ipcMain } = require('electron'); +const { v4: uuidv4 } = require('uuid'); +const { getDatabase, getPatientCount } = require('../../database/init.cjs'); +const { checkDataLimit } = require('../../license/tiers.cjs'); +const featureGate = require('../../license/featureGate.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + ipcMain.handle('entity:create', async (event, entityName, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + + const orgId = shared.getSessionOrgId(); + const tier = shared.getSessionTier(); + + if (entityName === 'AuditLog') throw new Error('Audit logs cannot be created directly'); + + try { + if (entityName === 'Patient') { + const currentCount = getPatientCount(orgId); + const limitCheck = checkDataLimit(tier, 'maxPatients', currentCount); + if (!limitCheck.allowed) throw new Error(`Patient limit reached (${limitCheck.limit}). Please upgrade your license to add more patients.`); + } + if (entityName === 'DonorOrgan') { + const currentCount = db.prepare('SELECT COUNT(*) as count FROM donor_organs WHERE org_id = ?').get(orgId).count; + const limitCheck = checkDataLimit(tier, 'maxDonors', currentCount); + if (!limitCheck.allowed) throw new Error(`Donor limit reached (${limitCheck.limit}). Please upgrade your license to add more donors.`); + } + if (featureGate.isReadOnlyMode()) { + throw new Error('Application is in read-only mode. Please activate or renew your license to make changes.'); + } + } catch (licenseError) { + const { app } = require('electron'); + const failOpen = !app.isPackaged && process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true'; + if (!failOpen) { + console.error('License check error:', licenseError.message); + throw licenseError; + } + console.warn('License check warning (dev mode):', licenseError.message); + if (licenseError.message.includes('limit reached') || licenseError.message.includes('read-only mode')) { + throw licenseError; + } + } + + const id = data.id || uuidv4(); + delete data.org_id; + const entityData = shared.sanitizeForSQLite({ ...data, id, org_id: orgId, created_by: currentUser.email }); + + const fields = Object.keys(entityData); + const placeholders = fields.map(() => '?').join(', '); + const values = fields.map(f => entityData[f]); + + try { + db.prepare(`INSERT INTO ${tableName} (${fields.join(', ')}) VALUES (${placeholders})`).run(...values); + } catch (dbError) { + if (dbError.code === 'SQLITE_CONSTRAINT_UNIQUE') { + if (entityName === 'Patient' && entityData.patient_id) + throw new Error(`A patient with ID "${entityData.patient_id}" already exists. Please use a unique Patient ID.`); + if (entityName === 'DonorOrgan' && entityData.donor_id) + throw new Error(`A donor with ID "${entityData.donor_id}" already exists. Please use a unique Donor ID.`); + throw new Error(`A ${entityName} with this identifier already exists.`); + } + throw dbError; + } + + let patientName = null; + if (entityName === 'Patient') patientName = `${data.first_name} ${data.last_name}`; + else if (data.patient_name) patientName = data.patient_name; + + shared.logAudit('create', entityName, id, patientName, `${entityName} created`, currentUser.email, currentUser.role); + return shared.getEntityById(tableName, id); + }); + + ipcMain.handle('entity:get', async (event, entityName, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + return shared.getEntityByIdAndOrg(tableName, id, shared.getSessionOrgId()); + }); + + ipcMain.handle('entity:update', async (event, entityName, id, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + const orgId = shared.getSessionOrgId(); + + if (entityName === 'AuditLog') throw new Error('Audit logs cannot be modified'); + + const existingEntity = shared.getEntityByIdAndOrg(tableName, id, orgId); + if (!existingEntity) throw new Error(`${entityName} not found or access denied`); + + const now = new Date().toISOString(); + const entityData = shared.sanitizeForSQLite({ ...data, updated_by: currentUser.email, updated_at: now }); + + delete entityData.id; + delete entityData.org_id; + delete entityData.created_at; + delete entityData.created_date; + delete entityData.created_by; + + const updates = Object.keys(entityData).map(k => `${k} = ?`).join(', '); + const values = [...Object.values(entityData), id, orgId]; + db.prepare(`UPDATE ${tableName} SET ${updates} WHERE id = ? AND org_id = ?`).run(...values); + + const entity = shared.getEntityByIdAndOrg(tableName, id, orgId); + let patientName = null; + if (entityName === 'Patient') patientName = `${entity.first_name} ${entity.last_name}`; + else if (entity.patient_name) patientName = entity.patient_name; + + shared.logAudit('update', entityName, id, patientName, `${entityName} updated`, currentUser.email, currentUser.role); + return entity; + }); + + ipcMain.handle('entity:delete', async (event, entityName, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + const orgId = shared.getSessionOrgId(); + + if (entityName === 'AuditLog') throw new Error('Audit logs cannot be deleted'); + + const entity = shared.getEntityByIdAndOrg(tableName, id, orgId); + if (!entity) throw new Error(`${entityName} not found or access denied`); + + let patientName = null; + if (entityName === 'Patient') patientName = `${entity.first_name} ${entity.last_name}`; + else if (entity?.patient_name) patientName = entity.patient_name; + + db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND org_id = ?`).run(id, orgId); + shared.logAudit('delete', entityName, id, patientName, `${entityName} deleted`, currentUser.email, currentUser.role); + return { success: true }; + }); + + ipcMain.handle('entity:list', async (event, entityName, orderBy, limit) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + return shared.listEntitiesByOrg(tableName, shared.getSessionOrgId(), orderBy, limit); + }); + + ipcMain.handle('entity:filter', async (event, entityName, filters, orderBy, limit) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const tableName = shared.entityTableMap[entityName]; + if (!tableName) throw new Error(`Unknown entity: ${entityName}`); + const orgId = shared.getSessionOrgId(); + const allowedColumns = shared.ALLOWED_ORDER_COLUMNS[tableName] || []; + + let query = `SELECT * FROM ${tableName} WHERE org_id = ?`; + const values = [orgId]; + + if (filters && typeof filters === 'object') { + delete filters.org_id; + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null) { + if (!allowedColumns.includes(key) && !['id', 'created_at', 'updated_at'].includes(key)) { + throw new Error(`Invalid filter field: ${key}`); + } + query += ` AND ${key} = ?`; + values.push(value); + } + } + } + + if (orderBy) { + const desc = orderBy.startsWith('-'); + const field = desc ? orderBy.substring(1) : orderBy; + if (!shared.isValidOrderColumn(tableName, field)) throw new Error(`Invalid sort field: ${field}`); + query += ` ORDER BY ${field} ${desc ? 'DESC' : 'ASC'}`; + } else { + query += ' ORDER BY created_at DESC'; + } + + if (limit) { + const parsedLimit = parseInt(limit, 10); + if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) throw new Error('Invalid limit value. Must be between 1 and 10000.'); + query += ` LIMIT ${parsedLimit}`; + } + + const rows = db.prepare(query).all(...values); + return rows.map(shared.parseJsonFields); + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/labs.cjs b/electron/ipc/handlers/labs.cjs new file mode 100644 index 0000000..27ef58c --- /dev/null +++ b/electron/ipc/handlers/labs.cjs @@ -0,0 +1,69 @@ +/** + * TransTrack - Lab Results IPC Handlers + * Handles: labs:* + * + * Strictly NON-CLINICAL and NON-ALLOCATIVE. + * Lab results are stored for DOCUMENTATION COMPLETENESS only. + */ + +const { ipcMain } = require('electron'); +const labsService = require('../../services/labsService.cjs'); +const shared = require('../shared.cjs'); + +function register() { + ipcMain.handle('labs:getCodes', async () => labsService.COMMON_LAB_CODES); + ipcMain.handle('labs:getSources', async () => labsService.LAB_SOURCES); + + ipcMain.handle('labs:create', async (event, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + return labsService.createLabResult(data, shared.getSessionOrgId(), currentUser.id, currentUser.email); + }); + + ipcMain.handle('labs:get', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getLabResultById(id, shared.getSessionOrgId()); + }); + + ipcMain.handle('labs:getByPatient', async (event, patientId, options) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getLabResultsByPatient(patientId, shared.getSessionOrgId(), options); + }); + + ipcMain.handle('labs:getLatestByPatient', async (event, patientId) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getLatestLabsByPatient(patientId, shared.getSessionOrgId()); + }); + + ipcMain.handle('labs:update', async (event, id, data) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + return labsService.updateLabResult(id, data, shared.getSessionOrgId(), currentUser.id, currentUser.email); + }); + + ipcMain.handle('labs:delete', async (event, id) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin' && currentUser.role !== 'coordinator') { + throw new Error('Coordinator or admin access required to delete lab results'); + } + return labsService.deleteLabResult(id, shared.getSessionOrgId(), currentUser.email); + }); + + ipcMain.handle('labs:getPatientStatus', async (event, patientId) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getPatientLabStatus(patientId, shared.getSessionOrgId()); + }); + + ipcMain.handle('labs:getDashboard', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getLabsDashboard(shared.getSessionOrgId()); + }); + + ipcMain.handle('labs:getRequiredTypes', async (event, organType) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return labsService.getRequiredLabTypes(shared.getSessionOrgId(), organType); + }); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/license.cjs b/electron/ipc/handlers/license.cjs new file mode 100644 index 0000000..1eb6fa7 --- /dev/null +++ b/electron/ipc/handlers/license.cjs @@ -0,0 +1,122 @@ +/** + * TransTrack - License Management IPC Handlers + * Handles: license:* + */ + +const { ipcMain } = require('electron'); +const { v4: uuidv4 } = require('uuid'); +const { getDatabase, getOrgLicense, getPatientCount, getUserCount } = require('../../database/init.cjs'); +const licenseManager = require('../../license/manager.cjs'); +const featureGate = require('../../license/featureGate.cjs'); +const { FEATURES, LICENSE_TIER, LICENSE_FEATURES, isEvaluationBuild } = require('../../license/tiers.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + ipcMain.handle('license:getInfo', async () => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const orgId = shared.getSessionOrgId(); + const license = getOrgLicense(orgId); + const tier = license?.tier || LICENSE_TIER.EVALUATION; + const features = LICENSE_FEATURES[tier] || LICENSE_FEATURES[LICENSE_TIER.EVALUATION]; + return { + tier, features, license, + usage: { patients: getPatientCount(orgId), users: getUserCount(orgId) }, + limits: { maxPatients: features.maxPatients, maxUsers: features.maxUsers }, + }; + }); + + ipcMain.handle('license:activate', async (event, licenseKey, customerInfo) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + const { currentUser } = shared.getSessionState(); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + + const orgId = shared.getSessionOrgId(); + if (isEvaluationBuild()) { + throw new Error('Cannot activate license on Evaluation build. Please download the Enterprise version.'); + } + + const result = await licenseManager.activateLicense(licenseKey, { ...customerInfo, orgId }); + if (result.success) { + const now = new Date().toISOString(); + const existingLicense = getOrgLicense(orgId); + + if (existingLicense) { + db.prepare( + 'UPDATE licenses SET license_key = ?, tier = ?, activated_at = ?, maintenance_expires_at = ?, customer_name = ?, customer_email = ?, updated_at = ? WHERE org_id = ?' + ).run(licenseKey, result.tier, now, result.maintenanceExpiry, customerInfo?.name || '', customerInfo?.email || '', now, orgId); + } else { + db.prepare( + 'INSERT INTO licenses (id, org_id, license_key, tier, activated_at, maintenance_expires_at, customer_name, customer_email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(uuidv4(), orgId, licenseKey, result.tier, now, result.maintenanceExpiry, customerInfo?.name || '', customerInfo?.email || '', now, now); + } + + shared.logAudit('license_activated', 'License', orgId, null, `License activated: ${result.tier}`, currentUser.email, currentUser.role); + } + return result; + }); + + ipcMain.handle('license:checkFeature', async (event, featureName) => { + if (!shared.validateSession()) throw new Error('Session expired. Please log in again.'); + return { enabled: shared.sessionHasFeature(featureName), tier: shared.getSessionTier() }; + }); + + ipcMain.handle('license:renewMaintenance', async (event, renewalKey, years) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + const result = await licenseManager.renewMaintenance(renewalKey, years); + shared.logAudit('maintenance_renewed', 'License', null, null, `Maintenance renewed for ${years} year(s)`, currentUser.email, currentUser.role); + return result; + }); + + ipcMain.handle('license:isValid', async () => licenseManager.isLicenseValid()); + ipcMain.handle('license:getTier', async () => licenseManager.getCurrentTier()); + + ipcMain.handle('license:getLimits', async () => { + const tier = licenseManager.getCurrentTier(); + return licenseManager.getTierLimits(tier); + }); + + ipcMain.handle('license:checkLimit', async (event, limitType, currentCount) => featureGate.canWithinLimit(limitType, currentCount)); + ipcMain.handle('license:getAppState', async () => featureGate.checkApplicationState()); + ipcMain.handle('license:getPaymentOptions', async () => licenseManager.getAllPaymentOptions()); + ipcMain.handle('license:getPaymentInfo', async (event, tier) => licenseManager.getPaymentInfo(tier)); + ipcMain.handle('license:getOrganization', async () => licenseManager.getOrganizationInfo()); + + ipcMain.handle('license:updateOrganization', async (event, updates) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + if (currentUser.role !== 'admin') throw new Error('Admin access required'); + return licenseManager.updateOrganizationInfo(updates); + }); + + ipcMain.handle('license:getMaintenanceStatus', async () => licenseManager.getMaintenanceStatus()); + + ipcMain.handle('license:getAuditHistory', async (event, limit) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return licenseManager.getLicenseAuditHistory(limit); + }); + + ipcMain.handle('license:isEvaluationBuild', async () => licenseManager.isEvaluationBuild()); + + ipcMain.handle('license:getEvaluationStatus', async () => ({ + isEvaluation: licenseManager.isEvaluationMode(), + daysRemaining: licenseManager.getEvaluationDaysRemaining(), + expired: licenseManager.isEvaluationExpired(), + inGracePeriod: licenseManager.isInEvaluationGracePeriod(), + })); + + ipcMain.handle('license:getAllFeatures', async () => { + return Object.values(FEATURES).map(feature => ({ + feature, + ...featureGate.canAccessFeature(feature), + })); + }); + + ipcMain.handle('license:checkFullAccess', async (event, options) => featureGate.checkFullAccess(options)); +} + +module.exports = { register }; diff --git a/electron/ipc/handlers/operations.cjs b/electron/ipc/handlers/operations.cjs new file mode 100644 index 0000000..4e8263e --- /dev/null +++ b/electron/ipc/handlers/operations.cjs @@ -0,0 +1,147 @@ +/** + * TransTrack - Operations IPC Handlers + * Handles: access:*, recovery:*, compliance:*, reconciliation:*, file:* + */ + +const { ipcMain, dialog } = require('electron'); +const { getDatabase } = require('../../database/init.cjs'); +const { FEATURES } = require('../../license/tiers.cjs'); +const featureGate = require('../../license/featureGate.cjs'); +const accessControl = require('../../services/accessControl.cjs'); +const disasterRecovery = require('../../services/disasterRecovery.cjs'); +const complianceView = require('../../services/complianceView.cjs'); +const offlineReconciliation = require('../../services/offlineReconciliation.cjs'); +const shared = require('../shared.cjs'); + +function register() { + const db = getDatabase(); + + // ===== ACCESS CONTROL ===== + ipcMain.handle('access:validateRequest', async (event, permission, justification) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return accessControl.validateAccessRequest(currentUser.role, permission, justification); + }); + + ipcMain.handle('access:logJustifiedAccess', async (event, permission, entityType, entityId, justification) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return accessControl.logAccessWithJustification( + db, currentUser.id, currentUser.email, currentUser.role, + permission, entityType, entityId, justification + ); + }); + + ipcMain.handle('access:getRoles', async () => accessControl.getAllRoles()); + ipcMain.handle('access:getJustificationReasons', async () => accessControl.JUSTIFICATION_REASONS); + + // ===== DISASTER RECOVERY ===== + ipcMain.handle('recovery:createBackup', async (event, options) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return await disasterRecovery.createBackup({ ...options, createdBy: currentUser.email }); + }); + + ipcMain.handle('recovery:listBackups', async () => disasterRecovery.listBackups()); + + ipcMain.handle('recovery:verifyBackup', async (event, backupId) => disasterRecovery.verifyBackup(backupId)); + + ipcMain.handle('recovery:restoreBackup', async (event, backupId) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser || currentUser.role !== 'admin') throw new Error('Admin access required for restore'); + return await disasterRecovery.restoreFromBackup(backupId, { restoredBy: currentUser.email }); + }); + + ipcMain.handle('recovery:getStatus', async () => disasterRecovery.getRecoveryStatus()); + + // ===== COMPLIANCE VIEW ===== + ipcMain.handle('compliance:getSummary', async () => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_summary', 'Viewed compliance summary'); + return complianceView.getComplianceSummary(); + }); + + ipcMain.handle('compliance:getAuditTrail', async (event, options) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_audit', 'Viewed audit trail'); + return complianceView.getAuditTrailForCompliance(options); + }); + + ipcMain.handle('compliance:getDataCompleteness', async () => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return complianceView.getDataCompletenessReport(); + }); + + ipcMain.handle('compliance:getValidationReport', async () => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + complianceView.logRegulatorAccess(db, currentUser.id, currentUser.email, 'view_validation', 'Viewed validation report'); + return complianceView.generateValidationReport(); + }); + + ipcMain.handle('compliance:getAccessLogs', async (event, options) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser) throw new Error('Not authenticated'); + return complianceView.getAccessLogReport(options); + }); + + // ===== OFFLINE RECONCILIATION ===== + ipcMain.handle('reconciliation:getStatus', async () => offlineReconciliation.getReconciliationStatus()); + ipcMain.handle('reconciliation:getPendingChanges', async () => offlineReconciliation.getPendingChanges()); + + ipcMain.handle('reconciliation:reconcile', async (event, strategy) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser || currentUser.role !== 'admin') throw new Error('Admin access required'); + return await offlineReconciliation.reconcilePendingChanges(strategy); + }); + + ipcMain.handle('reconciliation:setMode', async (event, mode) => { + const { currentUser } = shared.getSessionState(); + if (!currentUser || currentUser.role !== 'admin') throw new Error('Admin access required'); + return offlineReconciliation.setOperationMode(mode); + }); + + ipcMain.handle('reconciliation:getMode', async () => offlineReconciliation.getOperationMode()); + + // ===== FILE OPERATIONS ===== + ipcMain.handle('file:exportCSV', async (event, data, filename) => { + const exportCheck = featureGate.canAccessFeature(FEATURES.DATA_EXPORT); + if (!exportCheck.allowed) { + throw new Error('Data export is not available in your current license tier. Please upgrade to export data.'); + } + + const { currentUser } = shared.getSessionState(); + const fs = require('fs'); + const { filePath } = await dialog.showSaveDialog({ + title: 'Export CSV', + defaultPath: filename, + filters: [{ name: 'CSV Files', extensions: ['csv'] }], + }); + + if (filePath) { + if (data.length === 0) { + fs.writeFileSync(filePath, ''); + } else { + const headers = Object.keys(data[0]).join(','); + const rows = data.map(row => + Object.values(row).map(v => (typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : v)).join(',') + ); + fs.writeFileSync(filePath, [headers, ...rows].join('\n')); + } + shared.logAudit('export', 'System', null, null, `CSV exported: ${filename}`, currentUser.email, currentUser.role); + return { success: true, path: filePath }; + } + return { success: false }; + }); + + ipcMain.handle('file:backupDatabase', async (event, targetPath) => { + const { backupDatabase } = require('../../database/init.cjs'); + await backupDatabase(targetPath); + return { success: true }; + }); +} + +module.exports = { register }; diff --git a/electron/ipc/shared.cjs b/electron/ipc/shared.cjs new file mode 100644 index 0000000..ea3641a --- /dev/null +++ b/electron/ipc/shared.cjs @@ -0,0 +1,349 @@ +/** + * TransTrack - Shared IPC State & Utilities + * + * Centralizes session management, security constants, audit logging, + * and entity helper functions used by all IPC handler modules. + */ + +const { v4: uuidv4 } = require('uuid'); +const { + getDatabase, + isEncryptionEnabled, + verifyDatabaseIntegrity, + getEncryptionStatus, + getDefaultOrganization, + getOrgLicense, + getPatientCount, + getUserCount, +} = require('../database/init.cjs'); +const { LICENSE_TIER, LICENSE_FEATURES, hasFeature, checkDataLimit } = require('../license/tiers.cjs'); + +// ============================================================================= +// SESSION STORE +// ============================================================================= + +let currentSession = null; +let currentUser = null; +let sessionExpiry = null; + +function getSessionState() { + return { currentSession, currentUser, sessionExpiry }; +} + +function setSessionState(session, user, expiry) { + currentSession = session; + currentUser = user; + sessionExpiry = expiry; +} + +function clearSession() { + currentSession = null; + currentUser = null; + sessionExpiry = null; +} + +function getSessionOrgId() { + if (!currentUser || !currentUser.org_id) { + throw new Error('Organization context required. Please log in again.'); + } + return currentUser.org_id; +} + +function getSessionTier() { + if (!currentUser || !currentUser.license_tier) { + return LICENSE_TIER.EVALUATION; + } + return currentUser.license_tier; +} + +function sessionHasFeature(featureName) { + return hasFeature(getSessionTier(), featureName); +} + +function requireFeature(featureName) { + if (!sessionHasFeature(featureName)) { + const tier = getSessionTier(); + throw new Error( + `Feature '${featureName}' is not available in your ${tier} tier. Please upgrade to access this feature.` + ); + } +} + +function validateSession() { + if (!currentSession || !currentUser || !sessionExpiry) { + return false; + } + if (Date.now() > sessionExpiry) { + clearSession(); + return false; + } + if (!currentUser.org_id) { + clearSession(); + return false; + } + return true; +} + +// ============================================================================= +// SECURITY CONSTANTS +// ============================================================================= + +const MAX_LOGIN_ATTEMPTS = 5; +const LOCKOUT_DURATION_MS = 15 * 60 * 1000; +const SESSION_DURATION_MS = 8 * 60 * 60 * 1000; + +const ALLOWED_ORDER_COLUMNS = { + patients: ['id', 'patient_id', 'first_name', 'last_name', 'blood_type', 'organ_needed', 'medical_urgency', 'waitlist_status', 'priority_score', 'date_of_birth', 'email', 'phone', 'created_at', 'updated_at'], + donor_organs: ['id', 'donor_id', 'organ_type', 'blood_type', 'organ_status', 'status', 'patient_id', 'created_at', 'updated_at'], + matches: ['id', 'donor_organ_id', 'patient_id', 'patient_name', 'compatibility_score', 'match_status', 'priority_rank', 'created_at', 'updated_at'], + notifications: ['id', 'recipient_email', 'title', 'notification_type', 'is_read', 'priority_level', 'related_patient_id', 'created_at'], + notification_rules: ['id', 'rule_name', 'trigger_event', 'priority_level', 'is_active', 'created_at', 'updated_at'], + priority_weights: ['id', 'name', 'is_active', 'created_at', 'updated_at'], + ehr_integrations: ['id', 'name', 'type', 'is_active', 'last_sync_date', 'base_url', 'sync_frequency_minutes', 'created_at', 'updated_at'], + ehr_imports: ['id', 'integration_id', 'import_type', 'status', 'created_at', 'completed_date'], + ehr_sync_logs: ['id', 'integration_id', 'sync_type', 'direction', 'status', 'created_at', 'completed_date'], + ehr_validation_rules: ['id', 'field_name', 'rule_type', 'is_active', 'created_at', 'updated_at'], + audit_logs: ['id', 'action', 'entity_type', 'entity_id', 'patient_name', 'user_id', 'user_email', 'user_role', 'created_at'], + users: ['id', 'email', 'full_name', 'role', 'is_active', 'created_at', 'updated_at', 'last_login'], + readiness_barriers: ['id', 'patient_id', 'barrier_type', 'status', 'risk_level', 'owning_role', 'created_at', 'updated_at'], + adult_health_history_questionnaires: ['id', 'patient_id', 'status', 'expiration_date', 'owning_role', 'created_at', 'updated_at'], + organizations: ['id', 'name', 'type', 'status', 'created_at', 'updated_at'], + licenses: ['id', 'tier', 'activated_at', 'license_expires_at', 'created_at', 'updated_at'], + settings: ['id', 'key', 'value', 'updated_at'], + lab_results: ['id', 'patient_id', 'test_code', 'test_name', 'collected_at', 'resulted_at', 'source', 'created_at', 'updated_at'], + required_lab_types: ['id', 'test_code', 'test_name', 'organ_type', 'max_age_days', 'is_active', 'created_at', 'updated_at'], +}; + +const entityTableMap = { + Patient: 'patients', + DonorOrgan: 'donor_organs', + Match: 'matches', + Notification: 'notifications', + NotificationRule: 'notification_rules', + PriorityWeights: 'priority_weights', + EHRIntegration: 'ehr_integrations', + EHRImport: 'ehr_imports', + EHRSyncLog: 'ehr_sync_logs', + EHRValidationRule: 'ehr_validation_rules', + AuditLog: 'audit_logs', + User: 'users', + ReadinessBarrier: 'readiness_barriers', + AdultHealthHistoryQuestionnaire: 'adult_health_history_questionnaires', +}; + +const jsonFields = [ + 'priority_score_breakdown', 'conditions', 'notification_template', + 'metadata', 'import_data', 'error_details', 'document_urls', 'identified_issues', +]; + +const PASSWORD_REQUIREMENTS = { + minLength: 12, + requireUppercase: true, + requireLowercase: true, + requireNumber: true, + requireSpecial: true, +}; + +// ============================================================================= +// PASSWORD VALIDATION +// ============================================================================= + +function validatePasswordStrength(password) { + const errors = []; + if (!password || password.length < PASSWORD_REQUIREMENTS.minLength) { + errors.push(`Password must be at least ${PASSWORD_REQUIREMENTS.minLength} characters`); + } + if (PASSWORD_REQUIREMENTS.requireUppercase && !/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + if (PASSWORD_REQUIREMENTS.requireLowercase && !/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + if (PASSWORD_REQUIREMENTS.requireNumber && !/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + if (PASSWORD_REQUIREMENTS.requireSpecial && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + errors.push('Password must contain at least one special character (!@#$%^&*...)'); + } + return { valid: errors.length === 0, errors }; +} + +// ============================================================================= +// LOGIN ATTEMPT TRACKING +// ============================================================================= + +function checkAccountLockout(email) { + const db = getDatabase(); + const normalizedEmail = email.toLowerCase().trim(); + const attempt = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(normalizedEmail); + if (!attempt) return { locked: false, remainingTime: 0 }; + + if (attempt.locked_until) { + const lockedUntil = new Date(attempt.locked_until).getTime(); + const now = Date.now(); + if (now < lockedUntil) { + return { locked: true, remainingTime: Math.ceil((lockedUntil - now) / 1000 / 60) }; + } + db.prepare( + "UPDATE login_attempts SET attempt_count = 0, locked_until = NULL, updated_at = datetime('now') WHERE email = ?" + ).run(normalizedEmail); + } + return { locked: false, remainingTime: 0 }; +} + +function recordFailedLogin(email, ipAddress = null) { + const db = getDatabase(); + const normalizedEmail = email.toLowerCase().trim(); + const now = new Date().toISOString(); + const existing = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(normalizedEmail); + + if (existing) { + const newCount = existing.attempt_count + 1; + let lockedUntil = null; + if (newCount >= MAX_LOGIN_ATTEMPTS) { + lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS).toISOString(); + } + db.prepare( + 'UPDATE login_attempts SET attempt_count = ?, last_attempt_at = ?, locked_until = ?, ip_address = COALESCE(?, ip_address), updated_at = ? WHERE email = ?' + ).run(newCount, now, lockedUntil, ipAddress, now, normalizedEmail); + } else { + db.prepare( + 'INSERT INTO login_attempts (id, email, attempt_count, last_attempt_at, ip_address, created_at, updated_at) VALUES (?, ?, 1, ?, ?, ?, ?)' + ).run(uuidv4(), normalizedEmail, now, ipAddress, now, now); + } +} + +function clearFailedLogins(email) { + const db = getDatabase(); + db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email.toLowerCase().trim()); +} + +// ============================================================================= +// ORDER BY VALIDATION +// ============================================================================= + +function isValidOrderColumn(tableName, column) { + const allowedColumns = ALLOWED_ORDER_COLUMNS[tableName]; + if (!allowedColumns) return false; + return allowedColumns.includes(column); +} + +// ============================================================================= +// ENTITY HELPERS +// ============================================================================= + +function parseJsonFields(row) { + if (!row) return row; + const parsed = { ...row }; + for (const field of jsonFields) { + if (parsed[field] && typeof parsed[field] === 'string') { + try { parsed[field] = JSON.parse(parsed[field]); } catch (_) { /* keep string */ } + } + } + return parsed; +} + +/** @deprecated Use getEntityByIdAndOrg */ +function getEntityById(tableName, id) { + const db = getDatabase(); + const row = db.prepare(`SELECT * FROM ${tableName} WHERE id = ?`).get(id); + return row ? parseJsonFields(row) : null; +} + +function getEntityByIdAndOrg(tableName, id, orgId) { + if (!orgId) throw new Error('Organization context required for data access'); + const db = getDatabase(); + const row = db.prepare(`SELECT * FROM ${tableName} WHERE id = ? AND org_id = ?`).get(id, orgId); + return row ? parseJsonFields(row) : null; +} + +function listEntitiesByOrg(tableName, orgId, orderBy, limit) { + if (!orgId) throw new Error('Organization context required for data access'); + const db = getDatabase(); + let query = `SELECT * FROM ${tableName} WHERE org_id = ?`; + + if (orderBy) { + const desc = orderBy.startsWith('-'); + const field = desc ? orderBy.substring(1) : orderBy; + if (!isValidOrderColumn(tableName, field)) throw new Error(`Invalid sort field: ${field}`); + query += ` ORDER BY ${field} ${desc ? 'DESC' : 'ASC'}`; + } else { + query += ' ORDER BY created_at DESC'; + } + + if (limit) { + const parsedLimit = parseInt(limit, 10); + if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) { + throw new Error('Invalid limit value. Must be between 1 and 10000.'); + } + query += ` LIMIT ${parsedLimit}`; + } + + const rows = db.prepare(query).all(orgId); + return rows.map(parseJsonFields); +} + +function sanitizeForSQLite(entityData) { + for (const field of Object.keys(entityData)) { + const value = entityData[field]; + if (value === undefined) { entityData[field] = null; continue; } + if (typeof value === 'boolean') { entityData[field] = value ? 1 : 0; continue; } + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + entityData[field] = JSON.stringify(value); + } + } + return entityData; +} + +// ============================================================================= +// AUDIT LOGGING +// ============================================================================= + +function logAudit(action, entityType, entityId, patientName, details, userEmail, userRole) { + const db = getDatabase(); + const id = uuidv4(); + const orgId = currentUser?.org_id || 'SYSTEM'; + const now = new Date().toISOString(); + db.prepare( + 'INSERT INTO audit_logs (id, org_id, action, entity_type, entity_id, patient_name, details, user_email, user_role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(id, orgId, action, entityType, entityId, patientName, details, userEmail, userRole, now); +} + +// ============================================================================= +// EXPORTS +// ============================================================================= + +module.exports = { + // Session + getSessionState, + setSessionState, + clearSession, + getSessionOrgId, + getSessionTier, + sessionHasFeature, + requireFeature, + validateSession, + SESSION_DURATION_MS, + + // Security + validatePasswordStrength, + checkAccountLockout, + recordFailedLogin, + clearFailedLogins, + + // Constants + ALLOWED_ORDER_COLUMNS, + entityTableMap, + jsonFields, + + // Entity helpers + isValidOrderColumn, + parseJsonFields, + getEntityById, + getEntityByIdAndOrg, + listEntitiesByOrg, + sanitizeForSQLite, + + // Audit + logAudit, +}; diff --git a/electron/license/featureGate.cjs b/electron/license/featureGate.cjs index 33c279b..7a8d522 100644 --- a/electron/license/featureGate.cjs +++ b/electron/license/featureGate.cjs @@ -113,9 +113,11 @@ function checkApplicationState() { // SECURITY: Fail closed on license errors in production console.error('License check error:', error.message); - // Only fail-open in development mode with explicit flag - if (process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true') { - console.warn('WARNING: Failing open due to LICENSE_FAIL_OPEN flag'); + // Only fail-open in development mode with explicit flag AND unpackaged app + const { app } = require('electron'); + const isDevUnpackaged = !app.isPackaged && process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true'; + if (isDevUnpackaged) { + console.warn('WARNING: Failing open due to LICENSE_FAIL_OPEN flag (dev only)'); return { usable: true, info: null, @@ -123,7 +125,6 @@ function checkApplicationState() { }; } - // Default: fail closed - application not usable return { usable: false, reason: 'license_check_error', @@ -251,9 +252,10 @@ function canWithinLimit(limitType, currentCount) { // SECURITY: Fail closed on limit check errors console.error('Limit check error:', error.message); - // Only fail-open in development mode with explicit flag - if (process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true') { - console.warn('WARNING: Failing open on limit check due to LICENSE_FAIL_OPEN flag'); + const { app } = require('electron'); + const isDevUnpackaged = !app.isPackaged && process.env.NODE_ENV === 'development' && process.env.LICENSE_FAIL_OPEN === 'true'; + if (isDevUnpackaged) { + console.warn('WARNING: Failing open on limit check due to LICENSE_FAIL_OPEN flag (dev only)'); return { allowed: true, current: currentCount, diff --git a/package.json b/package.json index 8c31cdc..aa13c3b 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,9 @@ "build:enterprise:linux": "node scripts/set-build-version.cjs enterprise && npm run build && electron-builder --linux --config electron-builder.enterprise.json", "build:enterprise:all": "node scripts/set-build-version.cjs enterprise && npm run build && electron-builder --win --mac --linux --config electron-builder.enterprise.json", "build:all": "npm run build:eval:all && npm run build:enterprise:all", - "test": "node tests/cross-org-access.test.cjs", + "test": "node tests/cross-org-access.test.cjs && node tests/business-logic.test.cjs", "test:security": "node tests/cross-org-access.test.cjs", + "test:business": "node tests/business-logic.test.cjs", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "audit": "npm audit", @@ -117,7 +118,6 @@ "jspdf": "^4.0.0", "lodash": "^4.17.21", "lucide-react": "^0.577.0", - "moment": "^2.30.1", "next-themes": "^0.4.4", "react": "^18.2.0", "react-day-picker": "^8.10.1", diff --git a/src/App.jsx b/src/App.jsx index 0abdbae..d8c3f93 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import { HashRouter as Router, Route, Routes } from 'react-router-dom'; import PageNotFound from './lib/PageNotFound'; import { AuthProvider, useAuth } from '@/lib/AuthContext'; import UserNotRegisteredError from '@/components/UserNotRegisteredError'; +import ErrorBoundary from '@/components/ErrorBoundary'; import Login from '@/pages/Login'; import LicenseActivation from '@/pages/LicenseActivation'; import { EvaluationWatermark } from '@/components/license'; @@ -135,15 +136,17 @@ const AuthenticatedApp = () => { function App() { return ( - - - - - - - - - + + + + + + + + + + + ) } diff --git a/src/api/apiClient.js b/src/api/apiClient.js index f6a0f5c..ef9ab47 100644 --- a/src/api/apiClient.js +++ b/src/api/apiClient.js @@ -1,13 +1,36 @@ /** * TransTrack - API Client - * - * Re-exports the local client for use throughout the application. + * + * Provides a unified API interface with environment detection and + * centralized error handling. In Electron, delegates to the IPC-based + * localClient. In browser dev mode, uses a mock client. */ import { localClient } from './localClient'; -// Export local client as 'api' export const api = localClient; -// Default export -export default localClient; +/** + * Wrap an API call with standardized error handling. + * Catches IPC / network errors and returns a consistent shape. + * + * @param {Function} fn - Async function returning a result + * @returns {Promise<{ data: any, error: null } | { data: null, error: string }>} + */ +export async function safeApiCall(fn) { + try { + const data = await fn(); + return { data, error: null }; + } catch (err) { + const message = + err?.message || 'An unexpected error occurred. Please try again.'; + + if (message.includes('Session expired')) { + api.auth.redirectToLogin?.(); + } + + return { data: null, error: message }; + } +} + +export default api; diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..a5e6f68 --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,92 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo); + + if (window.electronAPI) { + try { + window.electronAPI.functions.invoke('logError', { + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }).catch(() => {}); + } catch (_) { /* best effort */ } + } + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback({ error: this.state.error, reset: this.handleReset }); + } + + return ( +
+
+
+ + + +
+ +

+ Something went wrong +

+

+ An unexpected error occurred. Your data is safe — the encrypted database has not been affected. +

+ + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+                {this.state.error.message}
+                {'\n'}
+                {this.state.error.stack}
+              
+ )} + +
+ + +
+ +

+ If this problem persists, please contact support at Trans_Track@outlook.com +

+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/ehr/FHIRImporter.jsx b/src/components/ehr/FHIRImporter.jsx index 2100f8c..092846b 100644 --- a/src/components/ehr/FHIRImporter.jsx +++ b/src/components/ehr/FHIRImporter.jsx @@ -45,7 +45,7 @@ export default function FHIRImporter({ onImportComplete }) { // Call import function const response = await api.functions.invoke('importFHIRData', { - fhir_bundle: fhirBundle, + fhir_data: fhirBundle, source_system: sourceSystem || 'Manual Upload', auto_create: autoCreate, auto_update: autoUpdate, diff --git a/src/components/labs/LabsPanel.jsx b/src/components/labs/LabsPanel.jsx index ccc2608..77f8b6d 100644 --- a/src/components/labs/LabsPanel.jsx +++ b/src/components/labs/LabsPanel.jsx @@ -32,7 +32,6 @@ import { Info, ChevronDown, ChevronUp, - CheckCircle, AlertTriangle, FileX, Filter, diff --git a/src/utils/index.ts b/src/utils/index.js similarity index 53% rename from src/utils/index.ts rename to src/utils/index.js index 487eb0f..03f2f5c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.js @@ -2,27 +2,24 @@ * TransTrack - Utility Functions */ -// Create URL for page navigation (using hash router for Electron) -export function createPageUrl(pageName: string): string { +export function createPageUrl(pageName) { if (pageName === 'Dashboard') { return '/'; } return `/${pageName}`; } -// Format date for display -export function formatDate(date: string | Date): string { +export function formatDate(date) { if (!date) return 'N/A'; const d = new Date(date); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', - day: 'numeric' + day: 'numeric', }); } -// Format date with time -export function formatDateTime(date: string | Date): string { +export function formatDateTime(date) { if (!date) return 'N/A'; const d = new Date(date); return d.toLocaleString('en-US', { @@ -30,12 +27,11 @@ export function formatDateTime(date: string | Date): string { month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', }); } -// Calculate age from date of birth -export function calculateAge(dateOfBirth: string | Date): number { +export function calculateAge(dateOfBirth) { if (!dateOfBirth) return 0; const dob = new Date(dateOfBirth); const today = new Date(); @@ -47,56 +43,53 @@ export function calculateAge(dateOfBirth: string | Date): number { return age; } -// Format priority score with color class -export function getPriorityClass(score: number): string { +export function getPriorityClass(score) { if (score >= 80) return 'text-red-600 bg-red-50'; if (score >= 60) return 'text-orange-600 bg-orange-50'; if (score >= 40) return 'text-yellow-600 bg-yellow-50'; return 'text-green-600 bg-green-50'; } -// Get priority label -export function getPriorityLabel(score: number): string { +export function getPriorityLabel(score) { if (score >= 80) return 'Critical'; if (score >= 60) return 'High'; if (score >= 40) return 'Medium'; return 'Low'; } -// Format blood type for display -export function formatBloodType(bloodType: string): string { +export function formatBloodType(bloodType) { return bloodType || 'Unknown'; } -// Format organ type for display -export function formatOrganType(organType: string): string { +export function formatOrganType(organType) { if (!organType) return 'Unknown'; return organType.replace(/_/g, '-').replace(/\b\w/g, l => l.toUpperCase()); } -// Export patient data to CSV format -export function exportToCSV(data: any[], filename: string): void { +export function exportToCSV(data, filename) { if (!data || data.length === 0) { console.warn('No data to export'); return; } - + const headers = Object.keys(data[0]); const csvContent = [ headers.join(','), - ...data.map(row => - headers.map(header => { - const value = row[header]; - if (value === null || value === undefined) return ''; - if (typeof value === 'object') return JSON.stringify(value).replace(/"/g, '""'); - if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; - }).join(',') - ) + ...data.map(row => + headers + .map(header => { + const value = row[header]; + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value).replace(/"/g, '""'); + if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + }) + .join(',') + ), ].join('\n'); - + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -108,24 +101,18 @@ export function exportToCSV(data: any[], filename: string): void { URL.revokeObjectURL(url); } -// Validate email format -export function isValidEmail(email: string): boolean { +export function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } -// Generate unique ID -export function generateId(): string { +export function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2); } -// Debounce function -export function debounce void>( - func: T, - wait: number -): (...args: Parameters) => void { - let timeout: NodeJS.Timeout | null = null; - return (...args: Parameters) => { +export function debounce(func, wait) { + let timeout = null; + return (...args) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; diff --git a/tests/business-logic.test.cjs b/tests/business-logic.test.cjs new file mode 100644 index 0000000..aca1fda --- /dev/null +++ b/tests/business-logic.test.cjs @@ -0,0 +1,512 @@ +/** + * TransTrack - Business Logic Tests + * + * Tests priority scoring, donor matching, notification rules, + * FHIR import/validation, and the shared IPC utilities. + */ + +'use strict'; + +const path = require('path'); +const crypto = require('crypto'); + +// ─── Mock Electron ────────────────────────────────────────────── + +const mockUserDataPath = path.join(__dirname, '.test-data-biz-' + Date.now()); +require.cache[require.resolve('electron')] = { + id: 'electron', + filename: 'electron', + loaded: true, + exports: { + app: { + getPath: () => mockUserDataPath, + isPackaged: false, + }, + ipcMain: { handle: () => {} }, + dialog: {}, + }, +}; + +const { v4: uuidv4 } = require('uuid'); + +// ─── Test helpers ────────────────────────────────────────────── + +const results = { passed: 0, failed: 0, errors: [] }; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + results.passed++; + } catch (e) { + console.log(` \u2717 ${name}`); + console.log(` ${e.message}`); + results.failed++; + results.errors.push({ test: name, error: e.message }); + } +} + +function assert(condition, msg) { if (!condition) throw new Error(msg); } +function assertEqual(a, b, msg) { if (a !== b) throw new Error(`${msg}: expected ${b}, got ${a}`); } +function assertInRange(val, min, max, msg) { + if (val < min || val > max) throw new Error(`${msg}: ${val} not in [${min}, ${max}]`); +} + +// ─── In-memory DB ────────────────────────────────────────────── + +const Database = require('better-sqlite3-multiple-ciphers'); +let db; + +function setupDB() { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE patients ( + id TEXT PRIMARY KEY, org_id TEXT, patient_id TEXT, first_name TEXT, last_name TEXT, + blood_type TEXT, organ_needed TEXT, medical_urgency TEXT, waitlist_status TEXT DEFAULT 'active', + priority_score REAL, priority_score_breakdown TEXT, + date_of_birth TEXT, date_added_to_waitlist TEXT, last_evaluation_date TEXT, + functional_status TEXT, prognosis_rating TEXT, meld_score REAL, las_score REAL, + pra_percentage REAL, cpra_percentage REAL, comorbidity_score REAL, + previous_transplants INTEGER DEFAULT 0, compliance_score REAL, + hla_typing TEXT, weight_kg REAL, height_cm REAL, + created_by TEXT, created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE priority_weights ( + id TEXT PRIMARY KEY, org_id TEXT, name TEXT, is_active INTEGER DEFAULT 1, + medical_urgency_weight REAL DEFAULT 30, time_on_waitlist_weight REAL DEFAULT 25, + organ_specific_score_weight REAL DEFAULT 25, evaluation_recency_weight REAL DEFAULT 10, + blood_type_rarity_weight REAL DEFAULT 10, evaluation_decay_rate REAL DEFAULT 0.5, + description TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE donor_organs ( + id TEXT PRIMARY KEY, org_id TEXT, donor_id TEXT, organ_type TEXT, blood_type TEXT, + organ_status TEXT, hla_typing TEXT, donor_age INTEGER, donor_weight_kg REAL, + created_at TEXT DEFAULT (datetime('now')), updated_at TEXT + ); + CREATE TABLE matches ( + id TEXT PRIMARY KEY, org_id TEXT, donor_organ_id TEXT, patient_id TEXT, patient_name TEXT, + compatibility_score REAL, blood_type_compatible INTEGER, abo_compatible INTEGER, + hla_match_score REAL, hla_a_match INTEGER, hla_b_match INTEGER, + hla_dr_match INTEGER, hla_dq_match INTEGER, + size_compatible INTEGER, match_status TEXT, priority_rank INTEGER, + virtual_crossmatch_result TEXT, physical_crossmatch_result TEXT, + predicted_graft_survival REAL, created_by TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE notifications ( + id TEXT PRIMARY KEY, org_id TEXT, recipient_email TEXT, title TEXT, message TEXT, + notification_type TEXT, is_read INTEGER DEFAULT 0, priority_level TEXT, + related_patient_id TEXT, related_patient_name TEXT, action_url TEXT, metadata TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE notification_rules ( + id TEXT PRIMARY KEY, org_id TEXT, rule_name TEXT, trigger_event TEXT, + conditions TEXT, priority_level TEXT, is_active INTEGER DEFAULT 1, + notification_template TEXT, description TEXT, + created_at TEXT DEFAULT (datetime('now')), updated_at TEXT + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, org_id TEXT, email TEXT, password_hash TEXT, + full_name TEXT, role TEXT DEFAULT 'user', is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE ehr_imports ( + id TEXT PRIMARY KEY, org_id TEXT, integration_id TEXT, import_type TEXT, + status TEXT, records_imported INTEGER, records_failed INTEGER, + error_details TEXT, created_by TEXT, completed_date TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE ehr_validation_rules ( + id TEXT PRIMARY KEY, org_id TEXT, field_name TEXT, rule_type TEXT, + is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT + ); + CREATE TABLE audit_logs ( + id TEXT PRIMARY KEY, org_id TEXT, action TEXT, entity_type TEXT, + entity_id TEXT, patient_name TEXT, details TEXT, + user_email TEXT, user_role TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + `); +} + +// ─── Seed helpers ────────────────────────────────────────────── + +function seedPatient(overrides = {}) { + const id = uuidv4(); + const defaults = { + id, org_id: 'ORG1', patient_id: `MRN-${id.slice(0,6)}`, + first_name: 'Test', last_name: 'Patient', + blood_type: 'O+', organ_needed: 'kidney', + medical_urgency: 'high', waitlist_status: 'active', + date_added_to_waitlist: new Date(Date.now() - 365 * 24 * 3600 * 1000).toISOString(), + last_evaluation_date: new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(), + functional_status: 'partially_dependent', + prognosis_rating: 'fair', + pra_percentage: 20, cpra_percentage: 25, + comorbidity_score: 3, compliance_score: 7, + previous_transplants: 0, weight_kg: 70, + hla_typing: 'A2 A24 B7 B44 DR4 DR11 DQ3', + }; + const p = { ...defaults, ...overrides }; + const cols = Object.keys(p); + const vals = Object.values(p); + db.prepare(`INSERT INTO patients (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`).run(...vals); + return p; +} + +function seedDonor(overrides = {}) { + const id = uuidv4(); + const defaults = { + id, org_id: 'ORG1', donor_id: `DON-${id.slice(0,6)}`, + organ_type: 'kidney', blood_type: 'O+', organ_status: 'available', + hla_typing: 'A2 A11 B7 B35 DR4 DR15 DQ3', + donor_age: 40, donor_weight_kg: 75, + }; + const d = { ...defaults, ...overrides }; + const cols = Object.keys(d); + db.prepare(`INSERT INTO donor_organs (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`).run(...Object.values(d)); + return d; +} + +function seedAdmin() { + db.prepare(`INSERT INTO users (id, org_id, email, password_hash, full_name, role) VALUES (?, ?, ?, ?, ?, ?)`).run( + uuidv4(), 'ORG1', 'admin@test.com', 'hash', 'Admin', 'admin' + ); +} + +// ─── Load functions module ───────────────────────────────────── + +const functions = require('../electron/functions/index.cjs'); +const mockContext = () => ({ + db, + currentUser: { id: 'u1', email: 'admin@test.com', role: 'admin', org_id: 'ORG1' }, + logAudit: () => {}, +}); + +// ================================================================= +// TEST SUITES +// ================================================================= + +async function runTests() { + console.log('\n========================================'); + console.log('Business Logic Tests'); + console.log('========================================\n'); + + setupDB(); + + // ─── 1. Priority Scoring ────────────────────────────────── + console.log('Suite 1: Priority Scoring'); + console.log('------------------------'); + + const p1 = seedPatient({ medical_urgency: 'critical', functional_status: 'fully_dependent', prognosis_rating: 'poor' }); + const p2 = seedPatient({ medical_urgency: 'low', functional_status: 'independent', prognosis_rating: 'excellent' }); + + const r1 = await functions.calculatePriorityAdvanced({ patient_id: p1.id }, mockContext()); + test('1.1: High-acuity patient gets high priority', () => { + assert(r1.success, 'Should succeed'); + assertInRange(r1.priority_score, 40, 100, 'Critical patient score'); + }); + + const r2 = await functions.calculatePriorityAdvanced({ patient_id: p2.id }, mockContext()); + test('1.2: Low-acuity patient gets lower priority', () => { + assert(r2.success, 'Should succeed'); + assert(r1.priority_score > r2.priority_score, `Critical (${r1.priority_score}) should exceed low (${r2.priority_score})`); + }); + + test('1.3: Score breakdown includes all components', () => { + const b = r1.breakdown; + assert(b.components.medical_urgency, 'Should have medical_urgency'); + assert(b.components.time_on_waitlist !== undefined, 'Should have time_on_waitlist'); + assert(b.components.organ_specific, 'Should have organ_specific'); + assert(b.components.evaluation_recency, 'Should have evaluation_recency'); + assert(b.components.blood_type_rarity, 'Should have blood_type_rarity'); + }); + + test('1.4: Score is clamped to [0, 100]', () => { + assertInRange(r1.priority_score, 0, 100, 'Score range'); + assertInRange(r2.priority_score, 0, 100, 'Score range'); + }); + + const pLiver = seedPatient({ organ_needed: 'liver', meld_score: 30 }); + const rLiver = await functions.calculatePriorityAdvanced({ patient_id: pLiver.id }, mockContext()); + test('1.5: Liver patient uses MELD scoring', () => { + assertEqual(rLiver.breakdown.components.organ_specific.type, 'MELD', 'Should use MELD'); + assertEqual(rLiver.breakdown.components.organ_specific.score, 30, 'MELD score should be 30'); + }); + + const pLung = seedPatient({ organ_needed: 'lung', las_score: 75 }); + const rLung = await functions.calculatePriorityAdvanced({ patient_id: pLung.id }, mockContext()); + test('1.6: Lung patient uses LAS scoring', () => { + assertEqual(rLung.breakdown.components.organ_specific.type, 'LAS', 'Should use LAS'); + }); + + test('1.7: Non-existent patient throws', async () => { + let threw = false; + try { await functions.calculatePriorityAdvanced({ patient_id: 'nonexistent' }, mockContext()); } + catch { threw = true; } + assert(threw, 'Should throw for missing patient'); + }); + + // ─── 2. Donor Matching ──────────────────────────────────── + console.log('\nSuite 2: Donor Matching'); + console.log('-----------------------'); + + seedAdmin(); + const donorA = seedDonor({ blood_type: 'O-', organ_type: 'kidney' }); + const pCompat1 = seedPatient({ blood_type: 'O+', organ_needed: 'kidney', hla_typing: 'A2 A11 B7 B35 DR4 DR15 DQ3', priority_score: 80 }); + const pCompat2 = seedPatient({ blood_type: 'A+', organ_needed: 'kidney', hla_typing: 'A1 A3 B8 B51 DR17 DR7', priority_score: 60 }); + seedPatient({ blood_type: 'B+', organ_needed: 'liver' }); // wrong organ + + const matchResult = await functions.matchDonorAdvanced( + { donor_organ_id: donorA.id, simulation_mode: true }, + mockContext() + ); + + test('2.1: Matching returns results for correct organ type', () => { + assert(matchResult.success, 'Should succeed'); + assert(matchResult.matches.length > 0, 'Should find matches'); + matchResult.matches.forEach(m => assertEqual(m.organ_needed, 'kidney', 'All matches should be kidney')); + }); + + test('2.2: Matches are sorted by compatibility descending', () => { + for (let i = 1; i < matchResult.matches.length; i++) { + assert( + matchResult.matches[i - 1].compatibility_score >= matchResult.matches[i].compatibility_score, + 'Should be sorted descending' + ); + } + }); + + test('2.3: Blood type compatibility is enforced', () => { + matchResult.matches.forEach(m => assert(m.blood_type_compatible, 'All matches should be blood type compatible')); + }); + + test('2.4: Simulation mode does not create DB records', () => { + assert(matchResult.simulation_mode, 'Should be simulation'); + const dbMatches = db.prepare('SELECT COUNT(*) as cnt FROM matches').get(); + assertEqual(dbMatches.cnt, 0, 'No matches in DB during simulation'); + }); + + test('2.5: Non-existent donor throws', async () => { + let threw = false; + try { await functions.matchDonorAdvanced({ donor_organ_id: 'ghost' }, mockContext()); } + catch { threw = true; } + assert(threw, 'Should throw for missing donor'); + }); + + // Hypothetical donor simulation + const hypoResult = await functions.matchDonorAdvanced({ + simulation_mode: true, + hypothetical_donor: { organ_type: 'kidney', blood_type: 'AB+', hla_typing: 'A1 A2 B7 B8 DR4 DR17' }, + }, mockContext()); + + test('2.6: Hypothetical donor matching works', () => { + assert(hypoResult.success, 'Should succeed'); + assert(hypoResult.simulation_mode, 'Should be simulation'); + }); + + // ─── 3. FHIR Validation ─────────────────────────────────── + console.log('\nSuite 3: FHIR Validation'); + console.log('------------------------'); + + const validBundle = { + resourceType: 'Bundle', + entry: [{ + resource: { + resourceType: 'Patient', + name: [{ given: ['John'], family: 'Doe' }], + birthDate: '1985-03-15', + }, + }], + }; + + const valResult = await functions.validateFHIRData({ fhir_data: validBundle }, mockContext()); + test('3.1: Valid FHIR bundle passes validation', () => { + assert(valResult.valid, 'Should be valid'); + assertEqual(valResult.errors.length, 0, 'No errors'); + }); + + const invalidBundle = { resourceType: 'Observation' }; + const invResult = await functions.validateFHIRData({ fhir_data: invalidBundle }, mockContext()); + test('3.2: Non-Bundle resource type fails validation', () => { + assert(!invResult.valid, 'Should be invalid'); + assert(invResult.errors.length > 0, 'Should have errors'); + }); + + const noNameBundle = { + resourceType: 'Bundle', + entry: [{ resource: { resourceType: 'Patient' } }], + }; + const noNameResult = await functions.validateFHIRData({ fhir_data: noNameBundle }, mockContext()); + test('3.3: Patient without name produces error', () => { + assert(!noNameResult.valid || noNameResult.errors.length > 0, 'Should flag missing name'); + }); + + const emptyBundle = { resourceType: 'Bundle', entry: [] }; + const emptyResult = await functions.validateFHIRData({ fhir_data: emptyBundle }, mockContext()); + test('3.4: Empty bundle produces warning', () => { + assert(emptyResult.warnings.length > 0, 'Should have warning for empty bundle'); + }); + + // ─── 4. FHIR Import ────────────────────────────────────── + console.log('\nSuite 4: FHIR Import'); + console.log('--------------------'); + + const importResult = await functions.importFHIRData({ + fhir_data: validBundle, + integration_id: 'int-123', + }, mockContext()); + + test('4.1: Valid FHIR import succeeds', () => { + assert(importResult.success, 'Should succeed'); + assertEqual(importResult.records_imported, 1, 'Should import 1 record'); + assertEqual(importResult.records_failed, 0, 'No failures'); + }); + + test('4.2: Import creates audit trail', () => { + const importRecord = db.prepare('SELECT * FROM ehr_imports WHERE id = ?').get(importResult.import_id); + assert(importRecord, 'Import record should exist'); + assertEqual(importRecord.status, 'completed', 'Status should be completed'); + }); + + test('4.3: Invalid FHIR data throws', async () => { + let threw = false; + try { + await functions.importFHIRData({ fhir_data: 'not json', integration_id: 'x' }, mockContext()); + } catch { threw = true; } + assert(threw, 'Should throw on invalid JSON'); + }); + + // ─── 5. Notification Rules ──────────────────────────────── + console.log('\nSuite 5: Notification Rules'); + console.log('--------------------------'); + + const rulePatient = seedPatient({ medical_urgency: 'critical', priority_score: 90 }); + + db.prepare(`INSERT INTO notification_rules (id, org_id, rule_name, trigger_event, conditions, priority_level, is_active, notification_template) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( + uuidv4(), 'ORG1', 'High Priority Alert', 'patient_update', + JSON.stringify({ priority_threshold: 80 }), + 'high', 1, + JSON.stringify({ title: 'Alert: {patient_name}', message: 'Priority {priority_score}' }) + ); + + const notifResult = await functions.checkNotificationRules({ + patient_id: rulePatient.id, + event_type: 'patient_update', + }, mockContext()); + + test('5.1: Matching rule triggers notification', () => { + assert(notifResult.success, 'Should succeed'); + assert(notifResult.notifications_created > 0, 'Should create notifications'); + }); + + const lowPatient = seedPatient({ medical_urgency: 'low', priority_score: 20 }); + const notifResult2 = await functions.checkNotificationRules({ + patient_id: lowPatient.id, + event_type: 'patient_update', + }, mockContext()); + + test('5.2: Below-threshold patient does not trigger rule', () => { + assertEqual(notifResult2.notifications_created, 0, 'Should not trigger'); + }); + + // ─── 6. Password Validation ────────────────────────────── + console.log('\nSuite 6: Password Validation (shared.cjs)'); + console.log('-----------------------------------------'); + + // Load the shared module + const shared = require('../electron/ipc/shared.cjs'); + + test('6.1: Strong password passes', () => { + const r = shared.validatePasswordStrength('MyStr0ng!Pass'); + assert(r.valid, 'Should be valid'); + assertEqual(r.errors.length, 0, 'No errors'); + }); + + test('6.2: Short password fails', () => { + const r = shared.validatePasswordStrength('Ab1!'); + assert(!r.valid, 'Should fail'); + assert(r.errors.some(e => e.includes('12 characters')), 'Should mention length'); + }); + + test('6.3: No uppercase fails', () => { + const r = shared.validatePasswordStrength('mystrongpass1!'); + assert(!r.valid, 'Should fail'); + assert(r.errors.some(e => e.includes('uppercase')), 'Should mention uppercase'); + }); + + test('6.4: No special character fails', () => { + const r = shared.validatePasswordStrength('MyStrongPass12'); + assert(!r.valid, 'Should fail'); + assert(r.errors.some(e => e.includes('special')), 'Should mention special char'); + }); + + test('6.5: Null password fails', () => { + const r = shared.validatePasswordStrength(null); + assert(!r.valid, 'Should fail'); + }); + + // ─── 7. Entity Helpers ──────────────────────────────────── + console.log('\nSuite 7: Entity Helpers'); + console.log('-----------------------'); + + test('7.1: parseJsonFields handles JSON strings', () => { + const row = { id: '1', priority_score_breakdown: '{"total":50}', name: 'test' }; + const parsed = shared.parseJsonFields(row); + assert(typeof parsed.priority_score_breakdown === 'object', 'Should parse JSON'); + assertEqual(parsed.priority_score_breakdown.total, 50, 'Should preserve value'); + }); + + test('7.2: parseJsonFields handles invalid JSON gracefully', () => { + const row = { id: '1', priority_score_breakdown: 'not-json' }; + const parsed = shared.parseJsonFields(row); + assertEqual(parsed.priority_score_breakdown, 'not-json', 'Should keep string'); + }); + + test('7.3: parseJsonFields handles null', () => { + assertEqual(shared.parseJsonFields(null), null, 'Should return null'); + }); + + test('7.4: isValidOrderColumn rejects unknown columns', () => { + assert(!shared.isValidOrderColumn('patients', 'DROP TABLE'), 'Should reject injection'); + assert(!shared.isValidOrderColumn('unknown_table', 'id'), 'Should reject unknown table'); + }); + + test('7.5: isValidOrderColumn accepts valid columns', () => { + assert(shared.isValidOrderColumn('patients', 'first_name'), 'Should accept first_name'); + assert(shared.isValidOrderColumn('patients', 'priority_score'), 'Should accept priority_score'); + }); + + test('7.6: sanitizeForSQLite converts types correctly', () => { + const data = { active: true, tags: ['a', 'b'], meta: { k: 'v' }, undef: undefined, name: 'test' }; + shared.sanitizeForSQLite(data); + assertEqual(data.active, 1, 'Boolean -> 1'); + assertEqual(data.tags, '["a","b"]', 'Array -> JSON'); + assertEqual(data.meta, '{"k":"v"}', 'Object -> JSON'); + assertEqual(data.undef, null, 'undefined -> null'); + assertEqual(data.name, 'test', 'String unchanged'); + }); + + // ─── Summary ────────────────────────────────────────────── + console.log('\n========================================'); + console.log('Test Summary'); + console.log('========================================'); + console.log(`Passed: ${results.passed}`); + console.log(`Failed: ${results.failed}`); + console.log(`Total: ${results.passed + results.failed}`); + + if (results.failed > 0) { + console.log('\nFailed Tests:'); + results.errors.forEach(({ test, error }) => console.log(` - ${test}: ${error}`)); + process.exit(1); + } else { + console.log('\n\u2713 All business logic tests passed!'); + } + + db.close(); +} + +runTests().catch(e => { console.error('Test runner error:', e); process.exit(1); });