diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..33f7b0af --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,84 @@ +name: Backend CI + +on: + push: + branches: + - main + paths: + - 'backend/**' + - 'docker-compose.yml' + - '.github/workflows/backend-ci.yml' + pull_request: + branches: + - main + paths: + - 'backend/**' + - 'docker-compose.yml' + - '.github/workflows/backend-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: diacify_db + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -prootpassword" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'backend/package-lock.json' + + - name: Install backend dependencies + run: npm ci + working-directory: backend + + - name: Run database migrations + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: 3306 + MYSQL_USER: root + MYSQL_PASSWORD: rootpassword + MYSQL_DATABASE: diacify_db + run: | + for file in backend/database/migrations/00[1-5]_*.sql; do + echo "Running migration: $file" + mysql -h 127.0.0.1 -P 3306 -u root -prootpassword diacify_db < "$file" + done + + - name: Run tests + env: + NODE_ENV: test + TEST_DB_HOST: 127.0.0.1 + TEST_DB_PORT: 3306 + TEST_DB_USER: root + TEST_DB_PASSWORD: rootpassword + TEST_DB_NAME: diacify_db + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + ML_INTERNAL_SECRET: ${{ secrets.ML_INTERNAL_SECRET }} + ML_SERVICE_URL: http://localhost:8001 + run: npm test + working-directory: backend + + deploy: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Trigger backend redeploy + run: curl --fail -X POST "${{ secrets.RENDER_BACKEND_HOOK }}" diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 00000000..d14d8cb6 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,42 @@ +name: Frontend CI + +on: + push: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + pull_request: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Run linter + run: npm run lint + working-directory: frontend + + - name: Build frontend + run: npm run build + working-directory: frontend diff --git a/.github/workflows/ml-ci.yml b/.github/workflows/ml-ci.yml new file mode 100644 index 00000000..af7cc14c --- /dev/null +++ b/.github/workflows/ml-ci.yml @@ -0,0 +1,84 @@ +name: ML CI + +on: + push: + branches: + - main + paths: + - 'machine-learning/**' + - 'docker-compose.yml' + - '.github/workflows/ml-ci.yml' + pull_request: + branches: + - main + paths: + - 'machine-learning/**' + - 'docker-compose.yml' + - '.github/workflows/ml-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: 'machine-learning/requirements.txt' + + - name: Install dependencies + run: pip install -r requirements.txt + working-directory: machine-learning + + - name: Train model for tests + working-directory: machine-learning + run: python train_model.py + + - name: Run pytest + env: + ML_INTERNAL_SECRET: ${{ secrets.ML_INTERNAL_SECRET || 'test_secret' }} + run: pytest tests/ -v + working-directory: machine-learning + + - name: Build Docker image for validation + run: docker build -t diacify-ml:ci . + working-directory: machine-learning + + deploy: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: machine-learning + push: true + tags: ghcr.io/${{ github.repository_owner }}/diacify-ml:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trigger ML service redeploy + run: curl --fail -X POST "${{ secrets.ML_DEPLOY_HOOK }}" diff --git a/README.md b/README.md index 4b78b58e..7294f4c6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ # Diacify -A clinical triage tool that ranks diabetic patients by urgency using an -ML risk score, longitudinal visit tracking, and appointment management. +A diabetic patient prioritisation system that answers one question: given the current recorded measurements of each patient in the queue, who needs to be seen first? -Stack: React · Node.js/Express · MySQL · Python/FastAPI · Random Forest · Clerk +Clinicians managing a diabetic cohort face a real problem — a flat list of patients with no indication of urgency. Diacify solves this by scoring each patient's clinical measurements against a trained Random Forest classifier, ranking the list by risk score, and surfacing whether each patient is improving or deteriorating over time. + +**Frontend:** React 18 + Vite · Clerk Auth +**Backend:** Node.js + Express · Zod · helmet · Winston +**Database:** MySQL 8 +**ML:** Python + FastAPI · scikit-learn · Random Forest +**DevOps:** Docker · GitHub Actions · Jest · pytest --- @@ -12,19 +17,21 @@ Stack: React · Node.js/Express · MySQL · Python/FastAPI · Random Forest · C ### Priority Dashboard ![Priority Dashboard](./assets/dashboard.png) -### Patient Detail +### Patient Details ![Patient Detail](./assets/patient-detail.png) ### Analytics ![Analytics](./assets/analytics.png) -### Book Appointment -![Book Appointment](./assets/book-appointment.png) +### Appointments +![Appointments](./assets/appointments.png) --- ## Architecture +Three independent services communicate over HTTP. The ML service is not publicly accessible — it is protected by a shared internal secret header validated on every request. + ```mermaid graph TD Clinician["🧑‍⚕️ Clinician"] @@ -75,30 +82,106 @@ graph TD PatientCtrl --- AuditLog ``` +**Key design decisions:** +- The ML service is decoupled from the patient save — visit data is written to MySQL first, then scored. If the ML service is unavailable, the visit is saved with `risk_category: pending` and no data is lost. +- Authentication is enforced at the backend — the Clerk session token is verified cryptographically on every protected route. `clerk_id` is never trusted from the request body. +- Every patient row is scoped to the authenticated clinician via `clerk_id`, enforced at the query level — not just the UI. + --- -## Tech Stack - -**Frontend** -- React 18 — component-based UI -- Vite — build tool and development server -- Clerk — authentication and session management -- react-chartjs-2 — trajectory and analytics charts -- axios — HTTP client - -**Backend** -- Node.js + Express.js — RESTful API -- MySQL 8 — relational data storage -- Zod — server-side input validation -- Clerk SDK — backend session token verification -- helmet — security headers -- express-rate-limit — rate limiting -- winston — structured logging - -**Machine Learning** -- Python + FastAPI — ML microservice -- scikit-learn — Random Forest classifier -- pandas / numpy — data preprocessing +## The Machine Learning Component + +### Dataset +Trained on the **Erbil Diabetes Dataset** (Mendeley Data, DOI: 10.17632/3snnp89967.1) — 662 patients referred by physicians for diabetes-related testing at a private laboratory in Erbil, Kurdistan Region of Iraq. + +Key preprocessing steps: +- BP encoding normalised to real mmHg (dataset mixed two formats across rows) +- BMI outliers capped at 70 (max raw value was 332.2 — data entry errors) +- FBS excluded (96.5% missing — only 23 of 662 records had values) + +### Model +Random Forest classifier (scikit-learn). Justified by Alsadi et al. (BMC Medical Informatics, 2024), Ashisha et al. (IJCIS, 2024), and Ooka et al. (BMJ Nutrition, 2021) — all demonstrating Random Forest superiority for diabetes classification over logistic regression and decision trees. + +### Classification Labels — ADA 2025 Grounded +Labels are derived from published clinical thresholds, not invented rules. + +**Primary driver — HbA1c using ADA 2025 diagnostic categories:** +- Low: HbA1c < 5.7% +- Medium: HbA1c 5.7–6.4% (prediabetes) +- High: HbA1c ≥ 6.5% (diabetes diagnostic threshold) + +**Secondary upgrade rule** — if a patient has 2 or more of the following flags raised, their label upgrades one tier (never downgraded): +1. BP ≥ 140/90 mmHg (Diabetes UK) +2. BMI ≥ 30 kg/m² (Diabetes UK) +3. RBS ≥ 126 mg/dL +4. TG/HDL ratio ≥ 2.8 (Baneu et al., Biomedicines, 2024) +5. LDL/HDL ratio ≥ 3.5 + +### Engineered Features +Four features derived from existing columns: +- **TG/HDL ratio** — surrogate for insulin resistance (Baneu et al., AUC 0.88) +- **LDL/HDL ratio** — dyslipidaemia indicator +- **Hypertension flag** — binary: systolic ≥ 140 OR diastolic ≥ 90 +- **Age-BMI interaction** — captures non-linear combined risk + +### Risk Score +The 0–100 continuous score is derived from class probabilities: +- Low category → mapped to 0–39 +- Medium category → mapped to 40–69 +- High category → mapped to 70–100 + +A `low_confidence` flag is set when max class probability < 0.40, surfaced on the patient detail page. + +--- + +## Before and After — What Changed + +This project was originally submitted as a university final year project.The examiner identified six critical issues. Every one has been addressed in this rebuild. + +| Issue | Original | Rebuilt | +|---|---|---| +| No backend authentication | `clerk_id` trusted from request body — any user could access any clinician's data | Clerk session token verified cryptographically on every route via `@clerk/express` | +| No unique patient identity | Auto-increment integer only | Human-readable `PAT-YYYY-NNNN` IDs generated at creation | +| No visit history | One record per patient, ever | Append-only `visits` table — full longitudinal history, trends calculated across visits | +| ML labels were synthetic | Self-invented point scoring system | ADA 2025 HbA1c thresholds + five-flag composite upgrade rule, every threshold citable | +| ML service crash = data loss | Patient save failed entirely if FastAPI unavailable | Visit saved first, ML called async, `pending` state if ML unreachable | +| No tests | Zero Jest, zero pytest | 8 Jest integration tests + 4 pytest tests, all passing | +| No CI/CD | No automated pipeline | Three GitHub Actions workflows with path filters, MySQL service container, GHCR push | +| Flat database schema | Single `patients` table | Normalised: `patients`, `visits`, `appointments`, `audit_log` | +| No security headers | No helmet, no rate limiting | helmet.js + express-rate-limit + Winston logging | +| ML service publicly accessible | Open CORS, no authentication | `X-Internal-Secret` header required on every ML request | + +--- + +## CI/CD + +Three GitHub Actions workflows with path filters — each only triggers when its own service changes. + +| Workflow | Trigger | What it does | +|---|---|---| +| `backend-ci.yml` | `backend/**` changes | Spins up MySQL 8 service container, runs migrations 001–005, runs 8 Jest tests, deploys to Render on merge to main | +| `frontend-ci.yml` | `frontend/**` changes | Runs ESLint, runs Vite build, Vercel redeploys automatically via GitHub integration | +| `ml-ci.yml` | `machine-learning/**` changes | Trains model from dataset, runs 4 pytest tests via FastAPI TestClient, validates Docker build, pushes image to GHCR on merge to main | + +--- + +## Docker + +Run the full stack locally with one command: + +```bash +# Copy and fill in your secrets +cp .env.example .env + +# Start all services +docker compose up --build +``` + +Services: +- `db` — MySQL 8 on port 3307, auto-runs migrations on first start +- `ml` — FastAPI on port 8001 +- `backend` — Express on port 3001, waits for healthy db and ml +- `frontend` — Vite dev server on port 5173 --- @@ -107,241 +190,217 @@ graph TD ### Dashboard - Summary cards showing High, Medium, and Low risk patient counts - Priority patient list sorted by risk score, HbA1c, then patient ID -- Search by Patient ID -- Filter by risk level +- Trend arrow per patient — worsening, improving, or stable vs previous visit +- Search by Patient ID and filter by risk level - This week's appointments widget +- Deterioration alert banner when any patient moves to a higher risk category ### Patient Detail -- Current risk score with semicircular gauge +- Current risk score with semicircular gauge (0–100) +- Confidence breakdown per class (Low / Medium / High %) - Top contributing factors with relative importance bars -- HbA1c trajectory chart with ADA reference lines at 5.7% and 6.5% +- HbA1c trajectory chart with ADA 2025 reference lines at 5.7% and 6.5% - Risk score trajectory chart with colour-coded bands -- Sparklines for BMI, Systolic BP, RBS, and Triglycerides +- Metric sparklines: BMI, Systolic BP, RBS, Triglycerides - Full visit history table with expandable rows - Appointment booking and history -### Patient Management -- Add, edit, and delete patient records -- Visit history — one row per clinical visit, full longitudinal record -- Client and server-side validation with clinical range checking -- Risk score and category automatically recalculated on every new visit - -### Machine Learning -- Random Forest classifier trained on the Erbil Diabetes Dataset (662 patients) -- Labels derived from ADA 2025 diagnostic thresholds — HbA1c primary driver -- Secondary upgrade rule using five clinical flags (BP, BMI, RBS, TG/HDL ratio, LDL/HDL ratio) -- 14 features including four engineered features: TG/HDL ratio, LDL/HDL ratio, hypertension flag, age-BMI interaction -- Risk scored on a 0–100 continuous scale -- Three risk categories: Low (0–39), Medium (40–69), High (70–100) -- Confidence percentages returned per class -- Low confidence flag when max class probability < 0.40 - ### Security - Clerk session token verified on every backend route -- ML service protected by shared internal secret header -- helmet.js security headers -- Rate limiting on all API routes -- Audit log on all patient data actions +- ML service protected by `X-Internal-Secret` shared header +- helmet.js security headers on all responses +- Rate limiting: 100 req/min read, 20 req/min write, 5 req/min auth +- Audit log on all patient data mutations +- Non-root user in ML Docker container --- ## Prerequisites -- Git -- Node.js v20 or higher -- npm -- MySQL 8.0 or higher -- Python 3.11 or higher +- Node.js v20+ +- Python 3.11+ +- MySQL 8.0+ +- Docker Desktop (optional — for compose setup) --- -## Setup +## Local Setup (Manual) -### 1. Clone the Repository +### 1. Clone ```bash -git clone https://github.com/deshanekanayaka/diabetic-risk-classification-system -cd diabetic-risk-classification-system +git clone https://github.com/deshanekanayaka/diacify.git +cd diacify ``` -### 2. Frontend Setup +### 2. Frontend ```bash cd frontend npm install ``` -Create a `.env` file in the `frontend/` directory: - +Create `frontend/.env`: ```env -VITE_API_URL=http://localhost:3300 -VITE_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here +VITE_API_URL=http://localhost:3001 +VITE_CLERK_PUBLISHABLE_KEY=pk_test_... ``` -### 3. Backend Setup +### 3. Backend ```bash cd backend npm install ``` -Create a `.env` file in the `backend/` directory: - +Create `backend/.env`: ```env -PORT=3300 +PORT=3001 NODE_ENV=development - DB_HOST=localhost DB_USER=root DB_PASSWORD=your_mysql_password DB_NAME=diacify_db DB_PORT=3306 - ML_SERVICE_URL=http://localhost:8001 -CLERK_SECRET_KEY=your_clerk_secret_key_here -ML_INTERNAL_SECRET=your_shared_secret_here -``` - -### 4. Database Setup - -```bash -mysql -u root -p -``` - -```sql -CREATE DATABASE diacify_db; -exit +CLERK_SECRET_KEY=sk_test_... +ML_INTERNAL_SECRET=your_shared_secret ``` -Run migrations in order: +### 4. Database ```bash +mysql -u root -p -e "CREATE DATABASE diacify_db;" cd backend/database/migrations -mysql -u root -p diacify_db < 001_create_patients.sql -mysql -u root -p diacify_db < 002_create_visits.sql -mysql -u root -p diacify_db < 003_create_appointments.sql -mysql -u root -p diacify_db < 004_create_audit_log.sql -mysql -u root -p diacify_db < 005_add_indices.sql +for f in 001 002 003 004 005; do + mysql -u root -p diacify_db < ${f}_*.sql +done ``` -### 5. Machine Learning Setup +### 5. ML Service ```bash cd machine-learning python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate +source venv/bin/activate pip install -r requirements.txt python train_model.py ``` -Create a `.env` file in the `machine-learning/` directory: - +Create `machine-learning/.env`: ```env -ML_INTERNAL_SECRET=your_shared_secret_here +ML_INTERNAL_SECRET=your_shared_secret PORT=8001 ``` ---- - -## Running the Project +### 6. Run -Open three terminal windows: +Open three terminals: -**Terminal 1 — Backend** ```bash -cd backend -npm run dev -``` -Runs at `http://localhost:3300` +# Terminal 1 — Backend +cd backend && npm run dev -**Terminal 2 — ML Service** -```bash -cd machine-learning -source venv/bin/activate -uvicorn app:app --reload --port 8001 -``` -Runs at `http://localhost:8001` +# Terminal 2 — ML Service +cd machine-learning && source venv/bin/activate && uvicorn app:app --reload --port 8001 -**Terminal 3 — Frontend** -```bash -cd frontend -npm run dev +# Terminal 3 — Frontend +cd frontend && npm run dev ``` -Runs at `http://localhost:5173` --- -## API Endpoints +## Environment Variables Reference -Base URL: `http://localhost:3300` +### Backend +| Variable | Description | +|---|---| +| `PORT` | Server port (default 3001) | +| `DB_HOST` | MySQL host | +| `DB_USER` | MySQL user | +| `DB_PASSWORD` | MySQL password | +| `DB_NAME` | Database name (`diacify_db`) | +| `DB_PORT` | MySQL port (default 3306) | +| `ML_SERVICE_URL` | URL of the FastAPI ML service | +| `CLERK_SECRET_KEY` | Clerk backend secret key | +| `ML_INTERNAL_SECRET` | Shared secret for ML service auth | + +### Frontend +| Variable | Description | +|---|---| +| `VITE_API_URL` | Backend API base URL | +| `VITE_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | + +### ML Service +| Variable | Description | +|---|---| +| `ML_INTERNAL_SECRET` | Must match backend value exactly | +| `PORT` | ML service port (default 8001) | + +--- + +## API Reference + +Base URL: `http://localhost:3001` + +All `/api/*` routes require `Authorization: Bearer `. ### Patients | Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/patients` | Get all patients for the authenticated clinician | -| GET | `/api/patients/:id` | Get patient by ID including all visits | -| POST | `/api/patients` | Add new patient and trigger ML scoring | -| PUT | `/api/patients/:id` | Update patient record | -| DELETE | `/api/patients/:id` | Delete patient | +|---|---|---| +| GET | `/api/patients` | Priority list, latest visit per patient, sorted by risk score | +| GET | `/api/patients/:id` | Patient detail with full visit history | +| POST | `/api/patients` | Create patient, trigger ML scoring | +| PUT | `/api/patients/:id` | Add new visit (append-only, never overwrites) | +| DELETE | `/api/patients/:id` | Delete patient, write to audit_log | ### Appointments | Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/appointments` | Book a new appointment | -| GET | `/api/appointments/:patientId` | Get all appointments for a patient | +|---|---|---| +| POST | `/api/appointments` | Book appointment | +| GET | `/api/appointments/:patientId` | Get patient appointments | ### Analytics | Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/analytics` | Get cohort analytics data | +|---|---|---| +| GET | `/api/analytics` | Cohort analytics — risk migration, HbA1c trends, distributions | ### Health | Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/health` | Service health check including DB and ML status | - -### ML Service -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/` | ML service health check | -| POST | `/predict` | Score a patient (internal use only) | +|---|---|---| +| GET | `/health` | Returns DB and ML service status | --- -## Environment Variables Reference +## Literature -### Backend -| Variable | Description | -|----------|-------------| -| `PORT` | Server port (default 3300) | -| `DB_HOST` | MySQL host | -| `DB_USER` | MySQL user | -| `DB_PASSWORD` | MySQL password | -| `DB_NAME` | Database name (diacify_db) | -| `DB_PORT` | MySQL port (default 3306) | -| `ML_SERVICE_URL` | URL of the ML FastAPI service | -| `CLERK_SECRET_KEY` | Clerk backend secret key | -| `ML_INTERNAL_SECRET` | Shared secret for ML service authentication | +| Source | Relevance | +|---|---| +| ADA Standards of Care 2025 | HbA1c thresholds (5.7% / 6.5%) used for classification labels | +| Diabetes UK guidelines | BP threshold (140/90 mmHg), BMI obesity threshold (30 kg/m²) | +| Baneu et al., Biomedicines, 2024 | TG/HDL ratio as insulin resistance surrogate, AUC 0.88 | +| Alsadi et al., BMC Medical Informatics, 2024 | Random Forest superiority for diabetes classification | +| Ashisha et al., IJCIS, 2024 | RF achieves 92–94% accuracy on diabetes datasets | +| Ooka et al., BMJ Nutrition, 2021 | RF outperforms MLR for HbA1c prediction | +| Shahraki et al., JRMS, 2025 | HbA1c + lipid panel as optimal feature combination | +| Erbil Diabetes Dataset, Mendeley, 2024 | Training dataset — DOI: 10.17632/3snnp89967.1 | -### Frontend -| Variable | Description | -|----------|-------------| -| `VITE_API_URL` | Backend API base URL | -| `VITE_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | +--- -### ML Service -| Variable | Description | -|----------|-------------| -| `ML_INTERNAL_SECRET` | Must match backend value | -| `PORT` | ML service port (default 8001) | +## What This System Is Not + +- It does not diagnose diabetes +- It does not predict who will develop diabetes +- It does not integrate with external EMR systems +- It is not a replacement for clinical judgement — all scores are decision support only --- ## Future Enhancements -- Integration with hospital Electronic Medical Record (EMR) systems -- Mobile application -- Automated notifications for follow-up appointments -- Patient outcome tracking and model retraining on real clinical labels +- EMR system integration +- Automated follow-up notifications +- Model retraining pipeline on real clinical outcome labels +- Patient outcome tracking - Export functionality for reports and analytics -- Google Calendar integration for appointment management \ No newline at end of file +- Mobile application \ No newline at end of file diff --git a/assets/analytics.png b/assets/analytics.png new file mode 100644 index 00000000..4e7768f1 Binary files /dev/null and b/assets/analytics.png differ diff --git a/assets/appointments.png b/assets/appointments.png new file mode 100644 index 00000000..cf112c2b Binary files /dev/null and b/assets/appointments.png differ diff --git a/assets/patient-detail.png b/assets/patient-detail.png new file mode 100644 index 00000000..c7750ad7 Binary files /dev/null and b/assets/patient-detail.png differ diff --git a/backend/controllers/appointmentController.js b/backend/controllers/appointmentController.js index 36545649..16ecb23f 100644 --- a/backend/controllers/appointmentController.js +++ b/backend/controllers/appointmentController.js @@ -126,6 +126,60 @@ const getAppointmentsByPatient = async (req, res) => { } }; +// GET /api/appointments +const getAllAppointments = async (req, res) => { + try { + const { userId: clerk_id } = req.auth; + + const rows = await db.query( + `SELECT a.appointment_id, a.patient_id, a.scheduled_date, + a.appointment_type, a.notes, a.status + FROM appointments a + JOIN patients p ON a.patient_id = p.patient_id + WHERE p.clerk_id = ? + ORDER BY a.scheduled_date ASC`, + [clerk_id] + ); + + const todayStr = new Date().toISOString().slice(0, 10); + + const formatRow = (row) => { + const dateStr = row.scheduled_date instanceof Date + ? row.scheduled_date.toISOString().slice(0, 10) + : String(row.scheduled_date).slice(0, 10); + return { + appointment_id: row.appointment_id, + patient_id: row.patient_id, + scheduled_date: dateStr, + appointment_type: row.appointment_type, + notes: row.notes, + status: row.status, + }; + }; + + const upcoming = (rows || []) + .filter(r => { + const d = new Date(r.scheduled_date).toISOString().slice(0, 10); + return r.status === 'scheduled' && d >= todayStr; + }) + .map(formatRow); + + const past = (rows || []) + .filter(r => { + const d = new Date(r.scheduled_date).toISOString().slice(0, 10); + return r.status !== 'scheduled' || d < todayStr; + }) + .sort((a, b) => new Date(b.scheduled_date) - new Date(a.scheduled_date)) + .map(formatRow); + + res.json({ success: true, data: { upcoming, past } }); + + } catch (error) { + logger.error('Error fetching all appointments: ' + error.message); + res.status(500).json({ success: false, message: 'Failed to fetch appointments' }); + } +}; + // GET /api/appointments/upcoming const getUpcomingAppointments = async (req, res) => { try { @@ -144,8 +198,9 @@ const getUpcomingAppointments = async (req, res) => { const now = new Date(); const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const data = (rows || []).map((row) => { - const d = new Date(row.scheduled_date); - const rowDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const rowDate = row.scheduled_date instanceof Date + ? row.scheduled_date.toISOString().slice(0, 10) + : String(row.scheduled_date).slice(0, 10); return { appointment_id: row.appointment_id, patient_id: row.patient_id, @@ -163,4 +218,50 @@ const getUpcomingAppointments = async (req, res) => { } }; -export { createAppointment, getAppointmentsByPatient, getUpcomingAppointments }; +const updateAppointmentStatus = async (req, res) => { + try { + const { userId: clerk_id } = req.auth; + const { id } = req.params; + const { status } = req.body; + + const allowed = ['completed', 'cancelled']; + if (!status || !allowed.includes(status)) { + return res.status(400).json({ + success: false, + message: 'status must be completed or cancelled', + }); + } + + // Verify appointment exists and belongs to this clinician + const existing = await db.queryOne( + 'SELECT appointment_id FROM appointments WHERE appointment_id = ? AND clerk_id = ?', + [id, clerk_id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + message: 'Appointment not found', + }); + } + + await db.execute( + 'UPDATE appointments SET status = ? WHERE appointment_id = ?', + [status, id] + ); + + res.json({ success: true, data: { appointment_id: id, status } }); + + } catch (error) { + logger.error('Error updating appointment status: ' + error.message); + res.status(500).json({ success: false, message: 'Failed to update appointment' }); + } +}; + +export { + createAppointment, + getAppointmentsByPatient, + getUpcomingAppointments, + getAllAppointments, + updateAppointmentStatus, +}; diff --git a/backend/database/initdb/001_create_patients.sql b/backend/database/initdb/001_create_patients.sql new file mode 100644 index 00000000..5115c50c --- /dev/null +++ b/backend/database/initdb/001_create_patients.sql @@ -0,0 +1,10 @@ +USE diacify_db; + +CREATE TABLE IF NOT EXISTS patients ( + patient_id VARCHAR(20) PRIMARY KEY, + clerk_id VARCHAR(100) NOT NULL, + sex VARCHAR(10) NOT NULL, + social_life VARCHAR(10) NOT NULL, + genetics VARCHAR(20) DEFAULT '0', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/database/initdb/002_create_visits.sql b/backend/database/initdb/002_create_visits.sql new file mode 100644 index 00000000..70b0b842 --- /dev/null +++ b/backend/database/initdb/002_create_visits.sql @@ -0,0 +1,27 @@ +USE diacify_db; + +CREATE TABLE IF NOT EXISTS visits ( + visit_id INT PRIMARY KEY AUTO_INCREMENT, + patient_id VARCHAR(20) NOT NULL, + visit_date DATE NOT NULL, + age INT NOT NULL, + bp_systolic DECIMAL(5,1) NOT NULL, + bp_diastolic DECIMAL(5,1) NOT NULL, + cholesterol DECIMAL(6,2), + triglycerides DECIMAL(6,2), + hdl DECIMAL(6,2), + ldl DECIMAL(6,2), + vldl DECIMAL(6,2), + hba1c DECIMAL(4,2) NOT NULL, + bmi DECIMAL(5,2) NOT NULL, + rbs DECIMAL(6,2), + risk_score DECIMAL(5,2), + risk_category VARCHAR(10) DEFAULT 'pending', + top_factors JSON, + confidence_low DECIMAL(5,2), + confidence_medium DECIMAL(5,2), + confidence_high DECIMAL(5,2), + low_confidence BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (patient_id) REFERENCES patients(patient_id) ON DELETE CASCADE +); diff --git a/backend/database/initdb/003_create_appointments.sql b/backend/database/initdb/003_create_appointments.sql new file mode 100644 index 00000000..69a2d446 --- /dev/null +++ b/backend/database/initdb/003_create_appointments.sql @@ -0,0 +1,15 @@ +USE diacify_db; + +CREATE TABLE IF NOT EXISTS appointments ( + appointment_id INT PRIMARY KEY AUTO_INCREMENT, + patient_id VARCHAR(20) NOT NULL, + clerk_id VARCHAR(100) NOT NULL, + scheduled_date DATE NOT NULL, + appointment_type VARCHAR(20) NOT NULL, + notes TEXT, + status VARCHAR(20) DEFAULT 'scheduled', + visit_id INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (patient_id) REFERENCES patients(patient_id) ON DELETE CASCADE, + FOREIGN KEY (visit_id) REFERENCES visits(visit_id) ON DELETE SET NULL +); diff --git a/backend/database/initdb/004_create_audit_log.sql b/backend/database/initdb/004_create_audit_log.sql new file mode 100644 index 00000000..cc175bf3 --- /dev/null +++ b/backend/database/initdb/004_create_audit_log.sql @@ -0,0 +1,10 @@ +USE diacify_db; + +CREATE TABLE IF NOT EXISTS audit_log ( + log_id INT PRIMARY KEY AUTO_INCREMENT, + clerk_id VARCHAR(100) NOT NULL, + action VARCHAR(20) NOT NULL, + patient_id VARCHAR(20), + changed_fields JSON, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/database/initdb/005_add_indices.sql b/backend/database/initdb/005_add_indices.sql new file mode 100644 index 00000000..bd1018ff --- /dev/null +++ b/backend/database/initdb/005_add_indices.sql @@ -0,0 +1,17 @@ +USE diacify_db; + +-- visits: patient lookup +CREATE INDEX idx_visits_patient_id + ON visits(patient_id); + +-- visits: latest visit per patient (used by priority list query) +CREATE INDEX idx_visits_patient_date + ON visits(patient_id, visit_date DESC); + +-- appointments: clinician schedule lookup +CREATE INDEX idx_appointments_clerk + ON appointments(clerk_id, scheduled_date); + +-- audit_log: patient history lookup +CREATE INDEX idx_audit_patient + ON audit_log(patient_id); diff --git a/backend/routes/appointmentRoutes.js b/backend/routes/appointmentRoutes.js index 8e895bd4..428a44cf 100644 --- a/backend/routes/appointmentRoutes.js +++ b/backend/routes/appointmentRoutes.js @@ -1,14 +1,18 @@ import express from 'express'; import { createAppointment, + getAllAppointments, getAppointmentsByPatient, getUpcomingAppointments, + updateAppointmentStatus, } from '../controllers/appointmentController.js'; const router = express.Router(); router.post('/', createAppointment); +router.get('/', getAllAppointments); router.get('/upcoming', getUpcomingAppointments); +router.patch('/:id/status', updateAppointmentStatus); router.get('/:patientId', getAppointmentsByPatient); export default router; diff --git a/backend/server.js b/backend/server.js index e80b64ec..e2fd50a0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -28,7 +28,7 @@ app.use(cors({ process.env.FRONTEND_URL, ].filter(Boolean), credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], })); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..20a72a8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + db: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: diacify_db + ports: + - "3307:3306" + volumes: + - db_data:/var/lib/mysql + - ./backend/database/initdb:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-prootpassword"] + interval: 10s + timeout: 5s + retries: 10 + + ml: + build: + context: ./machine-learning + ports: + - "8001:8001" + environment: + ML_INTERNAL_SECRET: ${ML_INTERNAL_SECRET} + PORT: 8001 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"] + interval: 15s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + ports: + - "3001:3001" + depends_on: + db: + condition: service_healthy + ml: + condition: service_healthy + environment: + PORT: 3001 + NODE_ENV: production + DB_HOST: db + DB_PORT: 3306 + DB_USER: root + DB_PASSWORD: rootpassword + DB_NAME: diacify_db + ML_SERVICE_URL: http://ml:8001 + CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} + ML_INTERNAL_SECRET: ${ML_INTERNAL_SECRET} + + frontend: + build: + context: ./frontend + ports: + - "5173:5173" + depends_on: + - backend + environment: + VITE_API_URL: http://localhost:3001 + VITE_CLERK_PUBLISHABLE_KEY: ${VITE_CLERK_PUBLISHABLE_KEY} + +volumes: + db_data: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e43ecfdb..866aa02a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -63,7 +63,7 @@ function AppSidebar({ user }) { {/* Nav links */}
); }; diff --git a/frontend/src/pages/PatientDetailPage.jsx b/frontend/src/pages/PatientDetailPage.jsx index e878665b..b3a4624e 100644 --- a/frontend/src/pages/PatientDetailPage.jsx +++ b/frontend/src/pages/PatientDetailPage.jsx @@ -3,7 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useAuth } from '@clerk/clerk-react'; import axios from '../utils/axiosConfig'; import { RiskPill, Sparkline, TrendArrow } from '@/components/risk-ui'; -import { riskColorClass } from '@/lib/risk'; import { Skeleton } from '@/components/ui/skeleton'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import BookAppointmentModal from '../components/BookAppointmentModal'; @@ -140,7 +139,6 @@ export default function PatientDetailPage({ clerkId }) { if (!patient) return null; - const c = riskColorClass(category); const lv = patient.latest_visit || {}; const pt = patient.patient || {}; diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile new file mode 100644 index 00000000..d142391d --- /dev/null +++ b/machine-learning/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app +USER appuser +COPY . . +EXPOSE 8001 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"] \ No newline at end of file diff --git a/machine-learning/app.py b/machine-learning/app.py index 51ec5add..51a0f2a2 100644 --- a/machine-learning/app.py +++ b/machine-learning/app.py @@ -100,9 +100,7 @@ def verify_internal_secret(api_key: str = Security(api_key_header)): ) MODEL_PATH = os.path.join( - os.path.dirname(__file__), - "models", - f"random_forest_{MODEL_VERSION}.pkl", + os.path.dirname(__file__), "models", "random_forest_model.pkl" ) try: diff --git a/machine-learning/models/model_metadata.json b/machine-learning/models/model_metadata.json index 8b8c72ab..a88d16ec 100644 --- a/machine-learning/models/model_metadata.json +++ b/machine-learning/models/model_metadata.json @@ -1,5 +1,5 @@ { - "training_date": "2026-05-30T20:10:30.283638", + "training_date": "2026-06-06T20:29:50.395834", "dataset_size": 665, "test_set_size": 133, "labelling_method": "ADA 2025 HbA1c thresholds + five-flag composite upgrade rule", diff --git a/machine-learning/requirements.txt b/machine-learning/requirements.txt index 9b3baa9e..06e05381 100644 --- a/machine-learning/requirements.txt +++ b/machine-learning/requirements.txt @@ -52,3 +52,6 @@ typst==0.14.8 tzdata==2025.3 uvicorn==0.40.0 watchdog==6.0.0 +httpx==0.28.1 +pytest==9.0.3 +python-dotenv==1.2.2 diff --git a/machine-learning/tests/__init__.py b/machine-learning/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/machine-learning/tests/test_model.py b/machine-learning/tests/test_model.py new file mode 100644 index 00000000..9a49d6dd --- /dev/null +++ b/machine-learning/tests/test_model.py @@ -0,0 +1,89 @@ +import os +import pickle +import pytest +from fastapi.testclient import TestClient +from app import app + + +TEST_SECRET = os.environ.get("ML_INTERNAL_SECRET", "test_secret") +client = TestClient(app) + + +def _valid_payload(**overrides): + """Return a valid prediction payload with optional overrides.""" + base = { + "age": 40, "sex": "male", + "bp_systolic": 120.0, "bp_diastolic": 80.0, + "cholesterol": 190.0, "triglycerides": 120.0, + "hdl": 50.0, "ldl": 110.0, "vldl": 24.0, + "hba1c": 5.4, "bmi": 24.0, "rbs": 100.0, + "genetics": 0, "social_life": "city", + } + base.update(overrides) + return base + + +def _model_path(): + """Find the .pkl file in the models directory.""" + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + models_dir = os.path.join(base, 'models') + for fname in os.listdir(models_dir): + if fname.endswith('.pkl'): + return os.path.join(models_dir, fname) + raise FileNotFoundError(f"No .pkl file found in {models_dir}") + + +def test_model_loads_with_expected_keys(): + """Test that the trained model artifact has required keys.""" + model_path = _model_path() + with open(model_path, 'rb') as f: + artifact = pickle.load(f) + + assert 'model' in artifact, "Artifact must contain 'model' key" + assert 'feature_names' in artifact, "Artifact must contain 'feature_names' key" + assert len(artifact['feature_names']) > 0, "Feature names list must not be empty" + + +def test_high_hba1c_predicts_high_risk(): + """Test that HbA1c 7.2% predicts high risk (ADA 2025 diabetes threshold ≥6.5%).""" + payload = _valid_payload(hba1c=7.2) + response = client.post("/predict", json=payload, headers={"X-Internal-Secret": TEST_SECRET}) + + assert response.status_code == 200, f"Expected 200, got {response.status_code}" + data = response.json() + assert data['risk_category'] == 'high', f"Expected 'high' risk, got {data['risk_category']}" + + +def test_normal_hba1c_predicts_low_risk(): + """Test that HbA1c 5.4% predicts low risk (below ADA 2025 prediabetes threshold 5.7%).""" + payload = _valid_payload(hba1c=5.4) + response = client.post("/predict", json=payload, headers={"X-Internal-Secret": TEST_SECRET}) + + assert response.status_code == 200, f"Expected 200, got {response.status_code}" + data = response.json() + assert data['risk_category'] == 'low', f"Expected 'low' risk, got {data['risk_category']}" + + +def test_predict_response_schema(): + """Test that /predict endpoint returns the complete response schema.""" + payload = _valid_payload(hba1c=6.2) + response = client.post("/predict", json=payload, headers={"X-Internal-Secret": TEST_SECRET}) + + assert response.status_code == 200, f"Expected 200, got {response.status_code}" + data = response.json() + + # Check all 7 required fields are present + required_fields = [ + 'risk_score', 'risk_category', 'confidence_low', + 'confidence_medium', 'confidence_high', 'top_factors', 'low_confidence' + ] + for field in required_fields: + assert field in data, f"Response missing required field: {field}" + + # Validate field types and values + assert isinstance(data['risk_score'], (int, float)), "risk_score must be a number" + assert data['risk_category'] in ('low', 'medium', 'high'), \ + f"risk_category must be 'low', 'medium', or 'high', got {data['risk_category']}" + assert isinstance(data['top_factors'], list), "top_factors must be a list" + assert isinstance(data['low_confidence'], bool), "low_confidence must be a boolean" + assert 0 <= data['risk_score'] <= 100, "risk_score must be between 0 and 100"