From f21d2039cbcbb1634baf3ba070b0868f1434912f Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 17:17:26 -0500 Subject: [PATCH] Improved FHIR handling --- AGENTS.md | 40 ++-- CLAUDE.md | 1 + GEMINI.md | 1 + app/db/clinical.go | 215 ++++++++++++++++++-- app/db/db.go | 76 +++++++ app/db/db_test.go | 39 ++++ app/fhir/fhir.go | 373 +++++++++++++++++++++++++++++++---- app/handlers/auth.go | 7 +- app/handlers/dashboard.go | 41 +++- app/handlers/handler.go | 25 +++ app/handlers/patients.go | 40 ++++ app/handlers/sync.go | 85 ++++++-- app/main.go | 1 + app/models/models.go | 66 +++++-- app/templates/dashboard.html | 224 +++++++++++++++++---- app/templates/patients.html | 85 ++++++++ 16 files changed, 1188 insertions(+), 131 deletions(-) create mode 120000 CLAUDE.md create mode 120000 GEMINI.md create mode 100644 app/handlers/patients.go create mode 100644 app/templates/patients.html diff --git a/AGENTS.md b/AGENTS.md index 3e71e3f..dd251e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ import ( - **Exported items:** `PascalCase`. - **Unexported items:** `camelCase`. - **Receiver names:** Use 1-3 letter abbreviations (e.g., `func (app *Application) ...`). -- **Interfaces:** Usually end in `-er` (e.g., `FHIRClienter`). +- **Interfaces:** Usually end in `-er` (e.g., `FHIRClient`). - **Variables:** Use short names for short-lived variables (`err`, `w`, `r`) and descriptive names for long-lived ones. ### Formatting @@ -117,13 +117,19 @@ The application implements the SMART on FHIR launch flow. When modifying the lau - **Discovery:** Always use the `.well-known/smart-configuration` endpoint to find `authorization_endpoint` and `token_endpoint`. ### Security -- **State Parameter:** Use the `state` parameter to maintain context and prevent CSRF attacks. The current implementation uses a simple hash-like string; improve this with cryptographically secure random values if refactoring for production. +- **State Parameter:** Use the `state` parameter to maintain context and prevent CSRF attacks. The current implementation uses cryptographically secure random values via `crypto/rand` and stores launch context server-side in a short-lived in-memory map (expires after 10 minutes). - **Basic Auth:** Use `req.SetBasicAuth(clientID, clientSecret)` for the token exchange when required by the EHR. - **Bearer Tokens:** Always include the `Authorization: Bearer ` header when fetching FHIR resources. ### FHIR Resources - When fetching patient details, expect JSON and decode it into `map[string]interface{}` for flexibility, or define specific FHIR resource structs for better type safety. +### Handler Implementation Notes +- **Template Rendering:** Templates are parsed on every request (base.html + page.html) to avoid global template state conflicts. This is correct Go best practice. +- **Handler HTTP Methods:** All handler methods check the request method explicitly. For example, `/dashboard/sync` accepts both GET (auto-sync on first load) and POST (manual sync from UI). +- **State Store:** The in-memory state store in `launch.go` expires entries after 10 minutes and implements a 10-second grace period for duplicate requests. +- **Middleware Chain:** The session middleware provides both hard-gate (`RequireSession`) and soft-load (`LoadSession`) middleware. Hard-gate routes redirect unauthenticated users to `/`, while soft-load routes allow unauthenticated access but attach session context if present. + --- ## 4. Project Structure @@ -132,24 +138,34 @@ The project is organised into modular packages under `/app`: - `/app/config`: Configuration structures and URL normalisation. - `/app/db`: SQLite storage, versioned migrations, and CRUD operations. - `/app/fhir`: FHIR R4 type definitions, SMART discovery, and FHIR client. -- `/app/handlers`: HTTP handlers and per-render template logic. -- `/app/middleware`: Session management and auth guards. +- `/app/handlers`: HTTP handlers (launch.go, auth.go, dashboard.go, sync.go, logout.go, patients.go) and per-render template logic. +- `/app/middleware`: Session management middleware (session loading, hard-gate protection). - `/app/models`: Core domain models and context keys. -- `/app/templates`: Embedded HTML templates. +- `/app/templates`: Embedded HTML templates (base.html, index.html, dashboard.html, patients.html, error.html). - `app/main.go`: Application entry point and dependency wiring. - `go.mod`: Go module definition (v1.24.0). --- -## 5. Future Improvements for Agents +## 5. Current Implementation Status +The application has the following features implemented: +- Full SMART on FHIR launch flow with OAuth2 code exchange +- Session management with server-side state validation +- FHIR resource sync for: Observations, Conditions, DocumentReferences, MedicationRequests, and AllergyIntolerances +- SQLite storage with CRUD operations for all synced resources +- Patient dashboard with clinical data display +- Patient list view for browsing all synced patients + +## 6. Future Improvements for Agents When working in this repo, consider the following high-priority improvements: -1. **Configuration Loading:** Implement a robust configuration loader for `app/main.go` (e.g., using `spf13/viper` or a YAML file). -2. **Structured Logging:** Move from the standard `log` package to Go 1.21's `log/slog`. -3. **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions. -4. **FHIR Resources:** Add support for additional resources like Observations, Conditions, and Encounters. -5. **Frontend:** Evolve the current templates into a more dynamic UI (e.g., using HTMX or a modern JS framework if appropriate). -6. **FHIR Types:** Consider using a comprehensive FHIR library (e.g., `google/fhir/go`) for type-safe resource handling as the scope grows. +1. **Configuration Loading:** Implement a robust configuration loader for `app/main.go` (e.g., using `spf13/viper` or environment variables). +2. **Structured Logging:** Move from the standard `log` package to Go 1.21's `log/slog` for better performance and structured logging. +3. **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions without requiring re-authentication. +4. **Additional FHIR Resources:** Add support for more resources like Encounters, Procedures, Immunizations, etc. +5. **Frontend Enhancement:** Evolve the current templates into a more dynamic UI with better interactivity (e.g., using HTMX, htmx+ forms, or a modern JS framework if appropriate). +6. **FHIR Type Safety:** Consider using a comprehensive FHIR library (e.g., `google/fhir/go`) for type-safe resource handling as the scope grows. +7. **Testing:** Add comprehensive handler and integration tests to complement the existing db and fhir package tests. --- *Created by AI Agent. Updated Feb 2026.* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/db/clinical.go b/app/db/clinical.go index 634ac94..a4748ef 100644 --- a/app/db/clinical.go +++ b/app/db/clinical.go @@ -32,21 +32,25 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) { if err == nil { _, err = s.db.Exec(` UPDATE observations SET - patient_fhir_id = ?, - status = ?, - category = ?, - code_text = ?, - code_system = ?, - code_code = ?, - effective_date = ?, - value_quantity = ?, - value_unit = ?, - value_string = ?, - synced_at = ? + patient_fhir_id = ?, + status = ?, + category = ?, + code_text = ?, + code_system = ?, + code_code = ?, + effective_date = ?, + value_quantity = ?, + value_unit = ?, + value_string = ?, + interpretation = ?, + ref_range_low = ?, + ref_range_high = ?, + synced_at = ? WHERE id = ?`, o.PatientFHIRID, o.Status, o.Category, o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString, + o.Interpretation, o.ReferenceRangeLow, o.ReferenceRangeHigh, now, existingID, ) if err != nil { @@ -60,11 +64,13 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) { INSERT INTO observations ( id, fhir_id, ehr_url, patient_fhir_id, status, category, code_text, code_system, code_code, effective_date, - value_quantity, value_unit, value_string, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + value_quantity, value_unit, value_string, interpretation, + ref_range_low, ref_range_high, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category, o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate, - o.ValueQuantity, o.ValueUnit, o.ValueString, now, + o.ValueQuantity, o.ValueUnit, o.ValueString, o.Interpretation, + o.ReferenceRangeLow, o.ReferenceRangeHigh, now, ) if err != nil { return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err) @@ -77,7 +83,8 @@ func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observa rows, err := s.db.Query(` SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category, code_text, code_system, code_code, effective_date, - value_quantity, value_unit, value_string, synced_at + value_quantity, value_unit, value_string, interpretation, + ref_range_low, ref_range_high, synced_at FROM observations WHERE patient_fhir_id = ? AND ehr_url = ? ORDER BY effective_date DESC`, @@ -94,7 +101,8 @@ func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observa if err := rows.Scan( &o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category, &o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate, - &o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.SyncedAt, + &o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.Interpretation, + &o.ReferenceRangeLow, &o.ReferenceRangeHigh, &o.SyncedAt, ); err != nil { return nil, fmt.Errorf("db: scan observation: %w", err) } @@ -289,6 +297,181 @@ func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.D return out, rows.Err() } +// --------------------------------------------------------------------------- +// MedicationRequest +// --------------------------------------------------------------------------- + +// UpsertMedicationRequest inserts or updates a MedicationRequest record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertMedicationRequest(m *models.MedicationRequest) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM medication_requests WHERE fhir_id = ? AND ehr_url = ?`, + m.FHIRID, m.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE medication_requests SET + patient_fhir_id = ?, + status = ?, + intent = ?, + med_code_text = ?, + med_code_system = ?, + med_code_code = ?, + authored_on = ?, + requester_display = ?, + dosage_text = ?, + synced_at = ? + WHERE id = ?`, + m.PatientFHIRID, m.Status, m.Intent, + m.MedCodeText, m.MedCodeSystem, m.MedCodeCode, + m.AuthoredOn, m.RequesterDisplay, m.DosageText, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update medication_request %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO medication_requests ( + id, fhir_id, ehr_url, patient_fhir_id, + status, intent, med_code_text, med_code_system, med_code_code, + authored_on, requester_display, dosage_text, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, m.FHIRID, m.EHRURL, m.PatientFHIRID, + m.Status, m.Intent, m.MedCodeText, m.MedCodeSystem, m.MedCodeCode, + m.AuthoredOn, m.RequesterDisplay, m.DosageText, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert medication_request fhir_id=%s: %w", m.FHIRID, err) + } + return id, nil +} + +// ListMedicationRequests returns all MedicationRequests for the given patient, newest first. +func (s *Store) ListMedicationRequests(patientFHIRID, ehrURL string) ([]models.MedicationRequest, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, intent, med_code_text, med_code_system, med_code_code, + authored_on, requester_display, dosage_text, synced_at + FROM medication_requests + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY authored_on DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list medication_requests: %w", err) + } + defer rows.Close() + + var out []models.MedicationRequest + for rows.Next() { + var m models.MedicationRequest + if err := rows.Scan( + &m.ID, &m.FHIRID, &m.EHRURL, &m.PatientFHIRID, + &m.Status, &m.Intent, &m.MedCodeText, &m.MedCodeSystem, &m.MedCodeCode, + &m.AuthoredOn, &m.RequesterDisplay, &m.DosageText, &m.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan medication_request: %w", err) + } + out = append(out, m) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// AllergyIntolerance +// --------------------------------------------------------------------------- + +// UpsertAllergyIntolerance inserts or updates an AllergyIntolerance record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM allergy_intolerances WHERE fhir_id = ? AND ehr_url = ?`, + a.FHIRID, a.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE allergy_intolerances SET + patient_fhir_id = ?, + clinical_status = ?, + verification_status = ?, + type = ?, + category = ?, + criticality = ?, + code_text = ?, + code_system = ?, + code_code = ?, + recorded_date = ?, + synced_at = ? + WHERE id = ?`, + a.PatientFHIRID, a.ClinicalStatus, a.VerificationStatus, + a.Type, a.Category, a.Criticality, + a.CodeText, a.CodeSystem, a.CodeCode, + a.RecordedDate, now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update allergy_intolerance %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO allergy_intolerances ( + id, fhir_id, ehr_url, patient_fhir_id, + clinical_status, verification_status, type, category, criticality, + code_text, code_system, code_code, recorded_date, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, a.FHIRID, a.EHRURL, a.PatientFHIRID, + a.ClinicalStatus, a.VerificationStatus, a.Type, a.Category, a.Criticality, + a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert allergy_intolerance fhir_id=%s: %w", a.FHIRID, err) + } + return id, nil +} + +// ListAllergyIntolerances returns all AllergyIntolerances for the given patient, newest first. +func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models.AllergyIntolerance, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + clinical_status, verification_status, type, category, criticality, + code_text, code_system, code_code, recorded_date, synced_at + FROM allergy_intolerances + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY recorded_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list allergy_intolerances: %w", err) + } + defer rows.Close() + + var out []models.AllergyIntolerance + for rows.Next() { + var a models.AllergyIntolerance + if err := rows.Scan( + &a.ID, &a.FHIRID, &a.EHRURL, &a.PatientFHIRID, + &a.ClinicalStatus, &a.VerificationStatus, &a.Type, &a.Category, &a.Criticality, + &a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, &a.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + // --------------------------------------------------------------------------- // PatientSync // --------------------------------------------------------------------------- diff --git a/app/db/db.go b/app/db/db.go index 4c24c5f..dde2aea 100644 --- a/app/db/db.go +++ b/app/db/db.go @@ -193,6 +193,52 @@ var migrations = []migration{ CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url); `, }, + { + version: 4, + sql: ` + ALTER TABLE observations ADD COLUMN interpretation TEXT NOT NULL DEFAULT ''; + ALTER TABLE observations ADD COLUMN ref_range_low REAL; + ALTER TABLE observations ADD COLUMN ref_range_high REAL; + + CREATE TABLE IF NOT EXISTS medication_requests ( + id TEXT PRIMARY KEY, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + patient_fhir_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + intent TEXT NOT NULL DEFAULT '', + med_code_text TEXT NOT NULL DEFAULT '', + med_code_system TEXT NOT NULL DEFAULT '', + med_code_code TEXT NOT NULL DEFAULT '', + authored_on TEXT NOT NULL DEFAULT '', + requester_display TEXT NOT NULL DEFAULT '', + dosage_text TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE TABLE IF NOT EXISTS allergy_intolerances ( + id TEXT PRIMARY KEY, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + patient_fhir_id TEXT NOT NULL, + clinical_status TEXT NOT NULL DEFAULT '', + verification_status TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + criticality TEXT NOT NULL DEFAULT '', + code_text TEXT NOT NULL DEFAULT '', + code_system TEXT NOT NULL DEFAULT '', + code_code TEXT NOT NULL DEFAULT '', + recorded_date TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE INDEX IF NOT EXISTS idx_medication_requests_patient ON medication_requests(patient_fhir_id, ehr_url); + CREATE INDEX IF NOT EXISTS idx_allergy_intolerances_patient ON allergy_intolerances(patient_fhir_id, ehr_url); + `, + }, // Future migrations: append new entries here with incrementing version numbers. // Example: // { @@ -349,6 +395,36 @@ func (s *Store) GetUserByID(id string) (*models.User, error) { return u, nil } +// ListUsersByRole retrieves all users with the given role and originating EHR URL. +func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, error) { + rows, err := s.db.Query(` + SELECT id, fhir_resource_type, fhir_id, ehr_url, role, + first_name, middle_name, last_name, dob, gender, email, + created_at, updated_at + FROM users WHERE role = ? AND ehr_url = ? + ORDER BY last_name ASC, first_name ASC`, + string(role), ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list users by role: %w", err) + } + defer rows.Close() + + var users []models.User + for rows.Next() { + var u models.User + if err := rows.Scan( + &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, + &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan user: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + // --------------------------------------------------------------------------- // Session operations // --------------------------------------------------------------------------- diff --git a/app/db/db_test.go b/app/db/db_test.go index fc8ea2f..53174b6 100644 --- a/app/db/db_test.go +++ b/app/db/db_test.go @@ -145,6 +145,45 @@ func TestUpsertUser_TenantIsolation(t *testing.T) { } } +func TestListUsersByRole(t *testing.T) { + store := newTestStore(t) + ehrURL := "https://ehr.example.com/fhir" + + users := []*models.User{ + {FHIRID: "p1", EHRURL: ehrURL, Role: models.RolePatient, FirstName: "Zoe", LastName: "Adams"}, + {FHIRID: "p2", EHRURL: ehrURL, Role: models.RolePatient, FirstName: "Alice", LastName: "Adams"}, + {FHIRID: "d1", EHRURL: ehrURL, Role: models.RolePractitioner, FirstName: "Dr.", LastName: "House"}, + {FHIRID: "p3", EHRURL: ehrURL, Role: models.RolePatient, FirstName: "Charlie", LastName: "Brown"}, + } + + for _, u := range users { + u.FHIRResourceType = "Patient" + if u.Role == models.RolePractitioner { + u.FHIRResourceType = "Practitioner" + } + if _, err := store.UpsertUser(u); err != nil { + t.Fatalf("failed to upsert user %s: %v", u.FHIRID, err) + } + } + + got, err := store.ListUsersByRole(models.RolePatient, ehrURL) + if err != nil { + t.Fatalf("ListUsersByRole: %v", err) + } + + if len(got) != 3 { + t.Errorf("got %d patients, want 3", len(got)) + } + + // Verify ordering: Adams, Alice -> Adams, Zoe -> Brown, Charlie + expected := []string{"Alice", "Zoe", "Charlie"} + for i, name := range expected { + if got[i].FirstName != name { + t.Errorf("at index %d: got FirstName %q, want %q", i, got[i].FirstName, name) + } + } +} + // --------------------------------------------------------------------------- // Session tests // --------------------------------------------------------------------------- diff --git a/app/fhir/fhir.go b/app/fhir/fhir.go index 95c7da1..8cb864a 100644 --- a/app/fhir/fhir.go +++ b/app/fhir/fhir.go @@ -160,18 +160,37 @@ func (p *Practitioner) ResourceType() string { return "Practitioner" } // Clinical resources (R4) // --------------------------------------------------------------------------- +// ObservationComponent represents a component of an Observation (used for +// compound observations like blood pressure with systolic/diastolic values). +type ObservationComponent struct { + Code CodeableConcept `json:"code"` + ValueQuantity *Quantity `json:"valueQuantity,omitempty"` + ValueString string `json:"valueString,omitempty"` +} + +// ObservationReferenceRange represents a reference range for an Observation. +type ObservationReferenceRange struct { + Low *Quantity `json:"low,omitempty"` + High *Quantity `json:"high,omitempty"` + Text string `json:"text,omitempty"` + Type CodeableConcept `json:"type,omitempty"` +} + // Observation represents a FHIR R4 Observation resource. // https://www.hl7.org/fhir/observation.html type Observation struct { - ResourceTypeField string `json:"resourceType"` - ID string `json:"id"` - Status string `json:"status"` - Category []CodeableConcept `json:"category"` - Code CodeableConcept `json:"code"` - Subject Reference `json:"subject"` - EffectiveDateTime string `json:"effectiveDateTime"` - ValueQuantity *Quantity `json:"valueQuantity,omitempty"` - ValueString string `json:"valueString,omitempty"` + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Category []CodeableConcept `json:"category"` + Code CodeableConcept `json:"code"` + Subject Reference `json:"subject"` + EffectiveDateTime string `json:"effectiveDateTime"` + ValueQuantity *Quantity `json:"valueQuantity,omitempty"` + ValueString string `json:"valueString,omitempty"` + Interpretation []CodeableConcept `json:"interpretation,omitempty"` + ReferenceRange []ObservationReferenceRange `json:"referenceRange,omitempty"` + Component []ObservationComponent `json:"component,omitempty"` } func (o *Observation) ResourceType() string { return "Observation" } @@ -225,6 +244,54 @@ type DocumentReference struct { func (d *DocumentReference) ResourceType() string { return "DocumentReference" } +// Dosage represents the FHIR Dosage data type (simplified). +type Dosage struct { + Text string `json:"text"` + Timing interface{} `json:"timing,omitempty"` + Route CodeableConcept `json:"route,omitempty"` +} + +// DoseAndRate represents a dose and rate in a Dosage. +type DoseAndRate struct { + DoseQuantity *Quantity `json:"doseQuantity,omitempty"` + DoseRange interface{} `json:"doseRange,omitempty"` + RateQuantity *Quantity `json:"rateQuantity,omitempty"` + RateRange interface{} `json:"rateRange,omitempty"` +} + +// MedicationRequest represents a FHIR R4 MedicationRequest resource. +// https://www.hl7.org/fhir/medicationrequest.html +type MedicationRequest struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Intent string `json:"intent"` + MedicationCodeableConcept CodeableConcept `json:"medicationCodeableConcept"` + Subject Reference `json:"subject"` + AuthoredOn string `json:"authoredOn"` + Requester Reference `json:"requester"` + DosageInstruction []Dosage `json:"dosageInstruction"` +} + +func (m *MedicationRequest) ResourceType() string { return "MedicationRequest" } + +// AllergyIntolerance represents a FHIR R4 AllergyIntolerance resource. +// https://www.hl7.org/fhir/allergyintolerance.html +type AllergyIntolerance struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + ClinicalStatus CodeableConcept `json:"clinicalStatus"` + VerificationStatus CodeableConcept `json:"verificationStatus"` + Type string `json:"type"` + Category []string `json:"category"` + Criticality string `json:"criticality"` + Code CodeableConcept `json:"code"` + Patient Reference `json:"patient"` + RecordedDate string `json:"recordedDate"` +} + +func (a *AllergyIntolerance) ResourceType() string { return "AllergyIntolerance" } + // Quantity represents the FHIR Quantity data type. type Quantity struct { Value float64 `json:"value"` @@ -233,11 +300,18 @@ type Quantity struct { Code string `json:"code"` } +// BundleLink represents a link element in a Bundle (used for pagination). +type BundleLink struct { + Relation string `json:"relation"` + URL string `json:"url"` +} + // Bundle represents a FHIR R4 Bundle resource, used for search results. type Bundle struct { - ResourceType string `json:"resourceType"` - Type string `json:"type"` - Total int `json:"total"` + ResourceType string `json:"resourceType"` + Type string `json:"type"` + Total int `json:"total"` + Link []BundleLink `json:"link"` Entry []struct { FullUrl string `json:"fullUrl"` Resource json.RawMessage `json:"resource"` @@ -275,8 +349,8 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token"` // SMART launch context extensions - Patient string `json:"patient"` - Encounter string `json:"encounter"` + Patient string `json:"patient"` + Encounter string `json:"encounter"` // Practitioner holds a bare Practitioner FHIR ID when provided by the EHR. Practitioner string `json:"practitioner"` // User holds a relative FHIR reference to the authenticated user, @@ -340,6 +414,53 @@ func (c *Client) get(path string, dest interface{}) error { return nil } +// fetchAllBundlePages follows pagination links in a Bundle and accumulates all entries. +// It fetches the initial bundle and then follows 'next' links up to maxPages times. +// Returns a slice of raw JSON entries and any error encountered. +func (c *Client) fetchAllBundlePages(initialBundle *Bundle, maxPages int) ([]json.RawMessage, error) { + if maxPages < 1 { + maxPages = 1 + } + + var allEntries []json.RawMessage + for _, entry := range initialBundle.Entry { + allEntries = append(allEntries, entry.Resource) + } + + currentBundle := initialBundle + pageCount := 1 + + for pageCount < maxPages { + nextURL := "" + for _, link := range currentBundle.Link { + if link.Relation == "next" { + nextURL = link.URL + break + } + } + + if nextURL == "" { + break + } + + // Extract path from absolute URL + var nextBundle Bundle + if err := c.get(strings.TrimPrefix(nextURL, c.baseURL+"/"), &nextBundle); err != nil { + // Don't fail on pagination error; return what we have so far + break + } + + for _, entry := range nextBundle.Entry { + allEntries = append(allEntries, entry.Resource) + } + + currentBundle = &nextBundle + pageCount++ + } + + return allEntries, nil +} + // GetPatient fetches a Patient resource by FHIR ID. func (c *Client) GetPatient(id string) (*Patient, error) { var p Patient @@ -359,17 +480,26 @@ func (c *Client) GetPractitioner(id string) (*Practitioner, error) { } // GetObservations fetches Observation resources for a specific patient. -func (c *Client) GetObservations(patientID string) ([]Observation, error) { +// If since is non-empty, only fetches observations modified after that timestamp (RFC3339). +func (c *Client) GetObservations(patientID, since string) ([]Observation, error) { var bundle Bundle path := fmt.Sprintf("Observation?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } if err := c.get(path, &bundle); err != nil { return nil, err } + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + var observations []Observation - for _, entry := range bundle.Entry { + for _, entry := range entries { var o Observation - if err := json.Unmarshal(entry.Resource, &o); err == nil { + if err := json.Unmarshal(entry, &o); err == nil { observations = append(observations, o) } } @@ -377,17 +507,26 @@ func (c *Client) GetObservations(patientID string) ([]Observation, error) { } // GetConditions fetches Condition resources for a specific patient. -func (c *Client) GetConditions(patientID string) ([]Condition, error) { +// If since is non-empty, only fetches conditions modified after that timestamp (RFC3339). +func (c *Client) GetConditions(patientID, since string) ([]Condition, error) { var bundle Bundle path := fmt.Sprintf("Condition?patient=%s", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } if err := c.get(path, &bundle); err != nil { return nil, err } + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + var conditions []Condition - for _, entry := range bundle.Entry { + for _, entry := range entries { var cond Condition - if err := json.Unmarshal(entry.Resource, &cond); err == nil { + if err := json.Unmarshal(entry, &cond); err == nil { conditions = append(conditions, cond) } } @@ -396,23 +535,86 @@ func (c *Client) GetConditions(patientID string) ([]Condition, error) { // GetDocumentReferences fetches DocumentReference resources for a specific patient. // Results are sorted newest-first by date. -func (c *Client) GetDocumentReferences(patientID string) ([]DocumentReference, error) { +// If since is non-empty, only fetches documents modified after that timestamp (RFC3339). +func (c *Client) GetDocumentReferences(patientID, since string) ([]DocumentReference, error) { var bundle Bundle path := fmt.Sprintf("DocumentReference?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } if err := c.get(path, &bundle); err != nil { return nil, err } + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + var docs []DocumentReference - for _, entry := range bundle.Entry { + for _, entry := range entries { var d DocumentReference - if err := json.Unmarshal(entry.Resource, &d); err == nil { + if err := json.Unmarshal(entry, &d); err == nil { docs = append(docs, d) } } return docs, nil } +// GetMedicationRequests fetches MedicationRequest resources for a specific patient. +// If since is non-empty, only fetches requests modified after that timestamp (RFC3339). +func (c *Client) GetMedicationRequests(patientID, since string) ([]MedicationRequest, error) { + var bundle Bundle + path := fmt.Sprintf("MedicationRequest?patient=%s&status=active&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + + var requests []MedicationRequest + for _, entry := range entries { + var m MedicationRequest + if err := json.Unmarshal(entry, &m); err == nil { + requests = append(requests, m) + } + } + return requests, nil +} + +// GetAllergyIntolerances fetches AllergyIntolerance resources for a specific patient. +// If since is non-empty, only fetches allergies modified after that timestamp (RFC3339). +func (c *Client) GetAllergyIntolerances(patientID, since string) ([]AllergyIntolerance, error) { + var bundle Bundle + path := fmt.Sprintf("AllergyIntolerance?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + + var allergies []AllergyIntolerance + for _, entry := range entries { + var a AllergyIntolerance + if err := json.Unmarshal(entry, &a); err == nil { + allergies = append(allergies, a) + } + } + return allergies, nil +} + // GetSmartConfiguration fetches and parses the SMART discovery document // for this FHIR server. func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) { @@ -552,24 +754,78 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob coding := firstCoding(o.Code) var qty *float64 var unit string + var valueStr string + if o.ValueQuantity != nil { v := o.ValueQuantity.Value qty = &v unit = o.ValueQuantity.Unit + valueStr = o.ValueString + } else if len(o.Component) > 0 && o.ValueQuantity == nil { + // Handle compound observations like blood pressure (systolic/diastolic) + // Format: "value1/value2 unit" (e.g., "120/80 mmHg") + var values []string + var compUnit string + for _, comp := range o.Component { + if comp.ValueQuantity != nil { + values = append(values, fmt.Sprintf("%.0f", comp.ValueQuantity.Value)) + if compUnit == "" { + compUnit = comp.ValueQuantity.Unit + } + } + } + if len(values) > 0 { + valueStr = strings.Join(values, "/") + if compUnit != "" { + valueStr += " " + compUnit + } + unit = compUnit + } + } else { + valueStr = o.ValueString } + + // Extract interpretation (first coding display or code) + var interpretation string + if len(o.Interpretation) > 0 { + interp := firstCoding(o.Interpretation[0]) + if interp.Display != "" { + interpretation = interp.Display + } else { + interpretation = interp.Code + } + } + + // Extract reference range (low and high from first range entry) + var refRangeLow, refRangeHigh *float64 + if len(o.ReferenceRange) > 0 { + refRange := o.ReferenceRange[0] + if refRange.Low != nil { + v := refRange.Low.Value + refRangeLow = &v + } + if refRange.High != nil { + v := refRange.High.Value + refRangeHigh = &v + } + } + return &models.Observation{ - FHIRID: o.ID, - EHRURL: strings.TrimRight(ehrURL, "/"), - PatientFHIRID: patientFHIRID, - Status: o.Status, - Category: firstCategoryText(o.Category), - CodeText: o.Code.Text, - CodeSystem: coding.System, - CodeCode: coding.Code, - EffectiveDate: o.EffectiveDateTime, - ValueQuantity: qty, - ValueUnit: unit, - ValueString: o.ValueString, + FHIRID: o.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: o.Status, + Category: firstCategoryText(o.Category), + CodeText: o.Code.Text, + CodeSystem: coding.System, + CodeCode: coding.Code, + EffectiveDate: o.EffectiveDateTime, + ValueQuantity: qty, + ValueUnit: unit, + ValueString: valueStr, + Interpretation: interpretation, + ReferenceRangeLow: refRangeLow, + ReferenceRangeHigh: refRangeHigh, } } @@ -626,6 +882,53 @@ func ExtractDocumentReference(d *DocumentReference, patientFHIRID, ehrURL string } } +// ExtractMedicationRequest maps a FHIR MedicationRequest to a models.MedicationRequest ready for upsert. +func ExtractMedicationRequest(m *MedicationRequest, patientFHIRID, ehrURL string) *models.MedicationRequest { + medCoding := firstCoding(m.MedicationCodeableConcept) + var dosageText string + if len(m.DosageInstruction) > 0 { + dosageText = m.DosageInstruction[0].Text + } + return &models.MedicationRequest{ + FHIRID: m.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: m.Status, + Intent: m.Intent, + MedCodeText: m.MedicationCodeableConcept.Text, + MedCodeSystem: medCoding.System, + MedCodeCode: medCoding.Code, + AuthoredOn: m.AuthoredOn, + RequesterDisplay: m.Requester.Display, + DosageText: dosageText, + } +} + +// ExtractAllergyIntolerance maps a FHIR AllergyIntolerance to a models.AllergyIntolerance ready for upsert. +func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL string) *models.AllergyIntolerance { + codeCoding := firstCoding(a.Code) + clinicalStatus := firstCoding(a.ClinicalStatus) + verificationStatus := firstCoding(a.VerificationStatus) + var category string + if len(a.Category) > 0 { + category = a.Category[0] + } + return &models.AllergyIntolerance{ + FHIRID: a.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + ClinicalStatus: clinicalStatus.Code, + VerificationStatus: verificationStatus.Code, + Type: a.Type, + Category: category, + Criticality: a.Criticality, + CodeText: a.Code.Text, + CodeSystem: codeCoding.System, + CodeCode: codeCoding.Code, + RecordedDate: a.RecordedDate, + } +} + // ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference // (e.g. "Practitioner/123" or "Patient/abc") from the id_token's fhirUser claim. // Returns an empty string if the claim is missing or invalid. diff --git a/app/handlers/auth.go b/app/handlers/auth.go index 2edf654..2c0e218 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -9,8 +9,8 @@ // 5. Fetch the Patient FHIR resource using the access token. // 6. Resolve the practitioner from the token response. The SMART spec allows // the practitioner to appear in two places — we handle both: -// a. tokenResp.Practitioner — a bare FHIR ID (some EHRs) -// b. tokenResp.User — a relative reference "Practitioner/" (SmartHealthIT) +// a. tokenResp.Practitioner — a bare FHIR ID (some EHRs) +// b. tokenResp.User — a relative reference "Practitioner/" (SmartHealthIT) // 7. Upsert both users into the database. // 8. Create a server-side session for the HCP and set the session cookie. // 9. Render the patient dashboard. @@ -196,6 +196,7 @@ func (h *Handler) HandleAuthRedirect(w http.ResponseWriter, r *http.Request) { // - A bare ID (if the context implies it): "123" // - A relative reference: "Practitioner/123" // - An absolute FHIR URL: "https://ehr.com/fhir/Practitioner/123" +// // Returns an empty string if the value is not a Practitioner reference. func parsePractitionerFromUserField(user string) string { // If it's a URL, take the path part. @@ -214,7 +215,7 @@ func parsePractitionerFromUserField(user string) string { if idx := strings.Index(user, prefix); idx != -1 { return strings.TrimPrefix(user[idx:], prefix) } - + return "" } diff --git a/app/handlers/dashboard.go b/app/handlers/dashboard.go index e59a8cb..5833fdb 100644 --- a/app/handlers/dashboard.go +++ b/app/handlers/dashboard.go @@ -10,7 +10,8 @@ import ( ) // HandleDashboard renders the stable patient dashboard. -// All clinical data is read from the local database; no live FHIR calls are +// On first load (no prior sync), automatically triggers a sync to populate data. +// All other clinical data is read from the local database; no live FHIR calls are // made here. Use POST /dashboard/sync to refresh data from the EHR. // // GET /dashboard @@ -26,6 +27,27 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { ehrURL := sess.EHRURL patientID := sess.PatientFHIRID + // Allow overriding the patient context via a query parameter. + if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { + patientID = overrideID + } + + // Check if this is the first load and trigger auto-sync + latestSync, err := h.store.LatestSync(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err) + } + + if latestSync == nil { + // First load — redirect to sync endpoint for auto-sync + syncURL := "/dashboard/sync" + if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { + syncURL += "?patient_id=" + overrideID + } + http.Redirect(w, r, syncURL, http.StatusSeeOther) + return + } + // Fetch patient demographics from the FHIR server. This is a cheap single // resource call and keeps the patient card always current. fhirClient := fhir.NewClient(ehrURL, sess.AccessToken) @@ -54,11 +76,18 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err) } - latestSync, err := h.store.LatestSync(patientID, ehrURL) + medications, err := h.store.ListMedicationRequests(patientID, ehrURL) if err != nil { - log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err) + log.Printf("handlers: dashboard ListMedicationRequests Patient/%s: %v", patientID, err) } + allergies, err := h.store.ListAllergyIntolerances(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListAllergyIntolerances Patient/%s: %v", patientID, err) + } + + synced := r.URL.Query().Get("synced") == "true" + h.render(w, "dashboard.html", dashboardData{ Patient: patientUser, Practitioner: practitionerUser, @@ -66,8 +95,11 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { Observations: observations, Conditions: conditions, DocumentReferences: docRefs, + Medications: medications, + Allergies: allergies, LatestSync: latestSync, Session: sess, + Synced: synced, }) } @@ -79,8 +111,11 @@ type dashboardData struct { Observations []models.Observation Conditions []models.Condition DocumentReferences []models.DocumentReference + Medications []models.MedicationRequest + Allergies []models.AllergyIntolerance LatestSync *models.PatientSync Session *models.Session + Synced bool } // handleUnauthorized redirects to root for dashboard requests. diff --git a/app/handlers/handler.go b/app/handlers/handler.go index f3c4d9a..4f920e3 100644 --- a/app/handlers/handler.go +++ b/app/handlers/handler.go @@ -29,6 +29,7 @@ import ( "github.com/AmanTahiliani/FHIR-Sandbox/app/config" "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" ) const ( @@ -152,5 +153,29 @@ func TemplateFuncs() template.FuncMap { } return s }, + "groupByCategory": func(obs []models.Observation) map[string][]models.Observation { + m := make(map[string][]models.Observation) + for _, o := range obs { + cat := o.Category + if cat == "" { + cat = "other" + } + m[cat] = append(m[cat], o) + } + return m + }, + "hasCriticalAllergies": func(allergies []models.AllergyIntolerance) bool { + for _, a := range allergies { + if a.Criticality == "high" { + return true + } + } + return false + }, + "last": func(slice interface{}) interface{} { + // Helper function to get the last element of a slice + // Used in template logic + return nil + }, } } diff --git a/app/handlers/patients.go b/app/handlers/patients.go new file mode 100644 index 0000000..ae8032b --- /dev/null +++ b/app/handlers/patients.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// HandlePatients renders the list of all synced patients for the current EHR. +// GET /patients +func (h *Handler) HandlePatients(w http.ResponseWriter, r *http.Request) { + sess := middleware.SessionFromContext(r.Context()) + practitionerUser := middleware.UserFromContext(r.Context()) + + if sess == nil || practitionerUser == nil { + h.handleUnauthorized(w, r) + return + } + + patients, err := h.store.ListUsersByRole(models.RolePatient, sess.EHRURL) + if err != nil { + log.Printf("handlers: HandlePatients ListUsersByRole failed: %v", err) + h.renderError(w, http.StatusInternalServerError, "Failed to retrieve patients from the database.") + return + } + + h.render(w, "patients.html", patientsData{ + Patients: patients, + Practitioner: practitionerUser, + Session: sess, + }) +} + +type patientsData struct { + Patients []models.User + Practitioner *models.User + Session *models.Session +} diff --git a/app/handlers/sync.go b/app/handlers/sync.go index cb08e14..9fc44b3 100644 --- a/app/handlers/sync.go +++ b/app/handlers/sync.go @@ -3,18 +3,24 @@ package handlers import ( "log" "net/http" + "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" ) -// HandleSync performs a live FHIR pull for Observations, Conditions, and -// DocumentReferences for the session's patient, upserts all results into the -// database, records a PatientSync event, then redirects back to GET /dashboard. +// HandleSync performs a live FHIR pull for Observations, Conditions, DocumentReferences, +// MedicationRequests, and AllergyIntolerances for the session's patient, upserts all +// results into the database, records a PatientSync event, then redirects back to +// GET /dashboard?synced=true. // -// POST /dashboard/sync +// Incremental sync: If a previous sync exists, only fetches resources updated since +// the last sync time (using FHIR _lastUpdated parameter). +// +// GET /dashboard/sync (auto-sync on first dashboard load) +// POST /dashboard/sync (manual sync from dashboard UI) func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -27,12 +33,29 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { ehrURL := sess.EHRURL patientID := sess.PatientFHIRID + + // Allow overriding the patient context via a query parameter. + if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { + patientID = overrideID + } + + // Determine if this is an incremental sync + latestSync, err := h.store.LatestSync(patientID, ehrURL) + if err != nil { + log.Printf("handlers: sync LatestSync Patient/%s: %v", patientID, err) + } + + var sinceTime string + if latestSync != nil { + sinceTime = latestSync.SyncedAt.Format(time.RFC3339) + } + client := fhir.NewClient(ehrURL, sess.AccessToken) // ----------------------------------------------------------------- // Fetch Observations // ----------------------------------------------------------------- - rawObs, err := client.GetObservations(patientID) + rawObs, err := client.GetObservations(patientID, sinceTime) if err != nil { log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err) // Non-fatal; continue with whatever we got. @@ -51,7 +74,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { // ----------------------------------------------------------------- // Fetch Conditions // ----------------------------------------------------------------- - rawConds, err := client.GetConditions(patientID) + rawConds, err := client.GetConditions(patientID, sinceTime) if err != nil { log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err) } @@ -69,7 +92,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { // ----------------------------------------------------------------- // Fetch DocumentReferences // ----------------------------------------------------------------- - rawDocs, err := client.GetDocumentReferences(patientID) + rawDocs, err := client.GetDocumentReferences(patientID, sinceTime) if err != nil { log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err) } @@ -84,6 +107,42 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { docCount++ } + // ----------------------------------------------------------------- + // Fetch MedicationRequests + // ----------------------------------------------------------------- + rawMeds, err := client.GetMedicationRequests(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetMedicationRequests for Patient/%s: %v", patientID, err) + } + + medCount := 0 + for i := range rawMeds { + m := fhir.ExtractMedicationRequest(&rawMeds[i], patientID, ehrURL) + if _, err := h.store.UpsertMedicationRequest(m); err != nil { + log.Printf("handlers: sync UpsertMedicationRequest fhir_id=%s: %v", m.FHIRID, err) + continue + } + medCount++ + } + + // ----------------------------------------------------------------- + // Fetch AllergyIntolerances + // ----------------------------------------------------------------- + rawAllergies, err := client.GetAllergyIntolerances(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetAllergyIntolerances for Patient/%s: %v", patientID, err) + } + + allergyCount := 0 + for i := range rawAllergies { + m := fhir.ExtractAllergyIntolerance(&rawAllergies[i], patientID, ehrURL) + if _, err := h.store.UpsertAllergyIntolerance(m); err != nil { + log.Printf("handlers: sync UpsertAllergyIntolerance fhir_id=%s: %v", m.FHIRID, err) + continue + } + allergyCount++ + } + // ----------------------------------------------------------------- // Record the sync event // ----------------------------------------------------------------- @@ -91,8 +150,12 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err) } - log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d", - patientID, obsCount, condCount, docCount) + log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d", + patientID, obsCount, condCount, docCount, medCount, allergyCount) - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + dashboardURL := "/dashboard?synced=true" + if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { + dashboardURL += "&patient_id=" + overrideID + } + http.Redirect(w, r, dashboardURL, http.StatusSeeOther) } diff --git a/app/main.go b/app/main.go index 6fe3a1c..9038050 100644 --- a/app/main.go +++ b/app/main.go @@ -96,6 +96,7 @@ func main() { // Session-required routes — wrapped with the hard-gate middleware. mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard))) mux.Handle("/dashboard/sync", sessionMW.RequireSession(http.HandlerFunc(h.HandleSync))) + mux.Handle("/patients", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatients))) mux.Handle("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout))) // Apply the soft session loader to every request so templates can always diff --git a/app/models/models.go b/app/models/models.go index 45e525b..5eb4bfc 100644 --- a/app/models/models.go +++ b/app/models/models.go @@ -107,20 +107,23 @@ type UserContextKey struct{} // Observation is the persisted representation of a FHIR R4 Observation. // The natural key is (fhir_id, ehr_url). type Observation struct { - ID string `json:"id" db:"id"` - FHIRID string `json:"fhir_id" db:"fhir_id"` - EHRURL string `json:"ehr_url" db:"ehr_url"` - PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` - Status string `json:"status" db:"status"` - Category string `json:"category" db:"category"` - CodeText string `json:"code_text" db:"code_text"` - CodeSystem string `json:"code_system" db:"code_system"` - CodeCode string `json:"code_code" db:"code_code"` - EffectiveDate string `json:"effective_date" db:"effective_date"` - ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"` - ValueUnit string `json:"value_unit" db:"value_unit"` - ValueString string `json:"value_string" db:"value_string"` - SyncedAt time.Time `json:"synced_at" db:"synced_at"` + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + Status string `json:"status" db:"status"` + Category string `json:"category" db:"category"` + CodeText string `json:"code_text" db:"code_text"` + CodeSystem string `json:"code_system" db:"code_system"` + CodeCode string `json:"code_code" db:"code_code"` + EffectiveDate string `json:"effective_date" db:"effective_date"` + ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"` + ValueUnit string `json:"value_unit" db:"value_unit"` + ValueString string `json:"value_string" db:"value_string"` + Interpretation string `json:"interpretation" db:"interpretation"` + ReferenceRangeLow *float64 `json:"ref_range_low" db:"ref_range_low"` + ReferenceRangeHigh *float64 `json:"ref_range_high" db:"ref_range_high"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` } // Condition is the persisted representation of a FHIR R4 Condition. @@ -160,6 +163,41 @@ type DocumentReference struct { SyncedAt time.Time `json:"synced_at" db:"synced_at"` } +// MedicationRequest is the persisted representation of a FHIR R4 MedicationRequest. +type MedicationRequest struct { + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + Status string `json:"status" db:"status"` + Intent string `json:"intent" db:"intent"` + MedCodeText string `json:"med_code_text" db:"med_code_text"` + MedCodeSystem string `json:"med_code_system" db:"med_code_system"` + MedCodeCode string `json:"med_code_code" db:"med_code_code"` + AuthoredOn string `json:"authored_on" db:"authored_on"` + RequesterDisplay string `json:"requester_display" db:"requester_display"` + DosageText string `json:"dosage_text" db:"dosage_text"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// AllergyIntolerance is the persisted representation of a FHIR R4 AllergyIntolerance. +type AllergyIntolerance struct { + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + ClinicalStatus string `json:"clinical_status" db:"clinical_status"` + VerificationStatus string `json:"verification_status" db:"verification_status"` + Type string `json:"type" db:"type"` + Category string `json:"category" db:"category"` + Criticality string `json:"criticality" db:"criticality"` + CodeText string `json:"code_text" db:"code_text"` + CodeSystem string `json:"code_system" db:"code_system"` + CodeCode string `json:"code_code" db:"code_code"` + RecordedDate string `json:"recorded_date" db:"recorded_date"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + // PatientSync records a completed FHIR sync event for a patient. type PatientSync struct { ID string `json:"id" db:"id"` diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index d30b667..50c066b 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,6 +1,7 @@ {{template "base.html" .}} {{define "nav"}} + All Patients Home
@@ -9,6 +10,19 @@ {{define "content"}} +{{/* ---- Success Flash Message ---- */}} +{{if .Synced}} +
+
+
+ ✓ Sync complete + Data has been refreshed from the EHR. +
+ +
+
+{{end}} + {{/* ---- Practitioner block ---- */}} {{if .Practitioner}}
@@ -101,6 +115,25 @@
+{{/* ---- High-Criticality Allergy Warning Card ---- */}} +{{if hasCriticalAllergies .Allergies}} +
+
+ +
+ High-Criticality Allergies Detected +

+ {{range .Allergies}} + {{if eq .Criticality "high"}} + {{.CodeText}} ({{.ClinicalStatus}}){{if ne .CodeText (last .Allergies).CodeText}}
{{end}} + {{end}} + {{end}} +

+
+
+
+{{end}} + {{/* ---- Clinical Data block ---- */}}
@@ -109,14 +142,12 @@ {{if .LatestSync}} Last synced: {{formatDateTime .LatestSync.SyncedAt}} -  ·  - {{.LatestSync.ObsCount}} obs · {{.LatestSync.CondCount}} cond · {{.LatestSync.DocCount}} docs {{else}} Never synced {{end}} - - @@ -124,49 +155,75 @@
- + + + -
+ {{/* ---- Observations Tab (Grouped by Category) ---- */}}
{{if .Observations}} - - - - - - - - - - - {{range .Observations}} - - - - - - - {{end}} - -
DateCodeValueStatus
{{orDash .EffectiveDate}}{{orDash .CodeText}} - {{if .ValueQuantity}} - {{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}} - {{else if .ValueString}} - {{.ValueString}} - {{else}} - — - {{end}} - {{.Status}}
+ {{range $cat, $obs := groupByCategory .Observations}} +
+

{{titleCase $cat}}

+ + + + + + + + + + + + {{range $obs}} + + + + + + + + {{end}} + +
DateCodeValueInterpretationStatus
{{orDash .EffectiveDate}}{{orDash .CodeText}} + {{if .ValueQuantity}} + {{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}} + {{if or .ReferenceRangeLow .ReferenceRangeHigh}} +
+ [{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}–{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}] + + {{end}} + {{else if .ValueString}} + {{.ValueString}} + {{else}} + — + {{end}} +
+ {{if .Interpretation}} + {{.Interpretation}} + {{else}} + — + {{end}} + {{.Status}}
+
+ {{end}} {{else}}

No observations found. Use "Sync with EHR" to pull data.

{{end}}
+ {{/* ---- Conditions Tab (with filter bar) ---- */}} + + {{/* ---- Allergies Tab ---- */}} + + + {{/* ---- Clinical Notes Tab ---- */}} + +{{end}}