From 63cb2b7b1cd9feed2a44ae974812bfa88052e92e Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 15:28:41 -0500 Subject: [PATCH 1/4] chore: remove DB and binary from repo, add .gitignore to exclude artifacts --- .gitignore | 47 +++ AGENTS.md | 155 +++++++++ README.md | 141 ++++---- app/config/config.go | 80 +++++ app/db/clinical.go | 340 ++++++++++++++++++ app/db/clinical_test.go | 345 +++++++++++++++++++ app/db/db.go | 421 ++++++++++++++++++++++ app/db/db_test.go | 256 ++++++++++++++ app/fhir/fhir.go | 651 +++++++++++++++++++++++++++++++++++ app/fhir/fhir_test.go | 131 +++++++ app/handlers/auth.go | 257 ++++++++++++++ app/handlers/dashboard.go | 89 +++++ app/handlers/handler.go | 156 +++++++++ app/handlers/launch.go | 177 ++++++++++ app/handlers/logout.go | 32 ++ app/handlers/sync.go | 98 ++++++ app/main.go | 449 ++++++------------------ app/middleware/session.go | 162 +++++++++ app/models/models.go | 172 +++++++++ app/templates/base.html | 238 +++++++++++++ app/templates/dashboard.html | 335 ++++++++++++++++++ app/templates/error.html | 13 + app/templates/index.html | 15 + go.mod | 14 + go.sum | 23 ++ 25 files changed, 4376 insertions(+), 421 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 app/config/config.go create mode 100644 app/db/clinical.go create mode 100644 app/db/clinical_test.go create mode 100644 app/db/db.go create mode 100644 app/db/db_test.go create mode 100644 app/fhir/fhir.go create mode 100644 app/fhir/fhir_test.go create mode 100644 app/handlers/auth.go create mode 100644 app/handlers/dashboard.go create mode 100644 app/handlers/handler.go create mode 100644 app/handlers/launch.go create mode 100644 app/handlers/logout.go create mode 100644 app/handlers/sync.go create mode 100644 app/middleware/session.go create mode 100644 app/models/models.go create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/error.html create mode 100644 app/templates/index.html create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e41ee1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Binaries +fhir-sandbox +bin/ +build/ + +# OS +.DS_Store +Thumbs.db + +# Go +*.exe +*.dll +*.so +*.dylib +*.test +coverage.out +*.coverprofile + +# Dependency directories +/vendor/ +node_modules/ + +# IDEs and editors +.vscode/ +.idea/ +*.iml + +# Environment / secrets +.env +.env.* +secrets.json + +# Databases +*.db +*.sqlite +*.sqlite3 +*.db-shm +*.db-wal + +# Logs and runtime +*.log +*.tmp +*.cache + +# Build outputs +dist/ +out/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3e71e3f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# AI Agent Developer Guide (AGENTS.md) + +This document provides essential information for AI coding agents (like yourself) to work efficiently in the **FHIR-Sandbox** repository. It covers build/test commands, code style guidelines, and the project's architecture. + +--- + +## 1. Build, Lint, and Test Commands + +### Build & Run +- **Build the binary:** + ```bash + go build -o fhir-sandbox app/main.go + ``` +- **Run the application:** + ```bash + go run app/main.go + ``` + The server starts on `http://localhost:8080` by default. + +### Testing +- **Run all tests:** + ```bash + go test ./... + ``` +- **Run a single test (by name):** + ```bash + go test -v -run TestName ./app/db + ``` +- **Run tests with coverage:** + ```bash + go test -cover ./... + ``` + *Note: New features MUST include `_test.go` files. We have 100% coverage on core DB and FHIR logic.* + +### Linting & Formatting +- **Standard Go formatting:** + ```bash + go fmt ./... + ``` +- **Import management (using goimports if available):** + ```bash + goimports -w . + ``` +- **Static analysis (vet):** + ```bash + go vet ./... + ``` + +--- + +## 2. Code Style Guidelines + +### General Principles +- **Simplicity:** Prefer standard library packages (e.g., `net/http`, `encoding/json`) over complex frameworks unless strictly necessary. +- **Explicit over Implicit:** Do not use magic values. Use constants or configuration fields. +- **Idiomatic Go:** Follow the patterns described in [Effective Go](https://golang.org/doc/effective_go). + +### Imports +Group imports into three blocks, separated by a blank line: +1. Standard library imports (alphabetical). +2. Third-party library imports (alphabetical). +3. Local project imports (alphabetical). + +```go +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) +``` + +### Naming Conventions +- **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`). +- **Variables:** Use short names for short-lived variables (`err`, `w`, `r`) and descriptive names for long-lived ones. + +### Formatting +- Use **tabs** for indentation (Go standard). +- Limit line length to **120 characters** where possible for readability. +- Braces: Standard Go placement (opening brace on the same line). + +### Types & Data Structures +- **Structs for Configuration:** Group related settings into nested structs (e.g., `ApplicationConfig`, `SMARTAppConfig`). +- **JSON Tags:** Always include JSON tags for structs that will be serialized or deserialized from JSON. + ```go + type LaunchContext struct { + LaunchID string `json:"launch"` + Patient string `json:"patient"` + } + ``` + +### Error Handling +- **Never ignore errors:** Always check `if err != nil`. +- **Wrap errors:** Use `fmt.Errorf("context: %w", err)` to provide additional context for debugging. +- **HTTP Error responses:** Use `http.Error(w, message, code)` for standard error reporting to the client. +- **Logging errors:** Log significant errors using `log.Printf` or a structured logger if introduced. + +### Logging +- Currently uses the standard `log` package. +- Always include context in logs (e.g., "Failed to fetch well-known URL: %v"). +- Do not log sensitive information like `client_secret` or `access_token` in production-like environments. + +--- + +## 3. SMART on FHIR Implementation Guidelines + +### Launch Flow +The application implements the SMART on FHIR launch flow. When modifying the launch logic: +- **`iss` parameter:** This is the FHIR server base URL. It must be validated. +- **`launch` parameter:** The opaque launch ID provided by the EHR. +- **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. +- **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. + +--- + +## 4. Project Structure + +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/models`: Core domain models and context keys. +- `/app/templates`: Embedded HTML templates. + +- `app/main.go`: Application entry point and dependency wiring. +- `go.mod`: Go module definition (v1.24.0). + +--- + +## 5. 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. + +--- +*Created by AI Agent. Updated Feb 2026.* diff --git a/README.md b/README.md index 2248f97..455055f 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,83 @@ -# FHIR-Sandbox: SMART on FHIR Integration Application +# FHIR-Sandbox: SMART on FHIR Healthcare Platform -A Go-based application that demonstrates integration with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. +A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data. -## Overview +## Features -This application implements a SMART on FHIR client that can: -- Launch from an EHR context -- Authenticate using OAuth 2.0 -- Retrieve patient information from FHIR servers -- Display patient details in a structured format +- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone). +- **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses. +- **SQLite Persistence:** Persists patient and practitioner data upon successful launch using a pure-Go SQLite driver (no CGO required). +- **Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies. +- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics and practitioner details. +- **Extensible Architecture:** Clean package separation (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`) designed for growth. + +## Architecture + +The project is structured into modular packages under `/app`: +- `/db`: Database schema, migrations, and CRUD operations using `modernc.org/sqlite`. +- `/fhir`: FHIR R4 resource definitions and SMART discovery/client logic. +- `/handlers`: HTTP request handlers and template rendering. +- `/middleware`: Session loading and authentication guards. +- `/models`: Shared data structures. +- `/templates`: HTML templates with layout inheritance. ## Prerequisites -- Go 1.16 or higher -- Access to a SMART on FHIR-compatible EHR system -- Client credentials (client ID and secret) from your EHR system +- **Go 1.24+** +- **No external database required** (uses embedded SQLite) + +## Getting Started + +1. **Clone the repository:** + ```bash + git clone https://github.com/AmanTahiliani/FHIR-Sandbox.git + cd FHIR-Sandbox + ``` + +2. **Run the application:** + ```bash + go run app/main.go + ``` + The server starts on `http://localhost:8080`. + +3. **Test with a Sandbox:** + Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/): + - **App Launch URL:** `http://localhost:8080/launch` + - **Redirect URL:** `http://localhost:8080/auth-redirect` + - The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox. ## Configuration -The application uses a configuration structure defined in `main.go`. You'll need to configure: +Configuration is currently managed in `app/main.go` via `config.AppConfig`. You can define multiple EHRs, set your redirect URI, and required scopes. -1. SMART App settings: - ```go - SMARTAppConfig { - redirectPath: "http://localhost:8080/auth-redirect", - clientID: "your-client-id", - clientSecret: "your-client-secret", - scopes: []string{"launch", "patient/*.read"}, - } -2. EHR Client settings: - ```go - EHRClientsConfig { - name: "EHR_NAME", - url: "https://your-ehr-fhir-endpoint.com", - authType: "auth-2", - clientID: "your-ehr-client-id", - clientSecret: "your-ehr-client-secret", - patientAPI: true, - } - ``` -## Installation - -1. Clone the repository: -```bash -git clone github.com/AmanTahiliani/fhir-sandbox.git -cd fhir-sandbox +```go +cfg := &config.AppConfig{ + DBPath: "fhir_sandbox.db", + SMART: config.SMARTConfig{ + RedirectURL: "http://localhost:8080/auth-redirect", + Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"}, + }, + EHRs: []config.EHRConfig{ + { + Name: "SmartHealthIT Sandbox (R4)", + FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", + ClientID: "your-client-id", + }, + }, +} ``` -2. Install dependencies: -```bash -go mod tidy -``` - -## Running the Application - -1. Start the application: -```bash -go run main.go -``` -2. The application will start and listen on `http://localhost:8080`. - -## Endpoints -- `/` - Root endpoint, displays welcome message -- `/launch` - SMART launch endpoint -- `/auth-redirect` - OAuth2 redirect endpoint - -## SMART on FHIR Launch Flow - -- EHR system initiates launch with parameters: - - `launch` - Launch ID - - `iss` - FHIR server URL -- Application authenticates with the EHR: - - Retrieves SMART configuration - - Initiates OAuth2 flow - - Exchanges code for access token -- Application retrieves and displays patient information ## Testing -You can test the application using a FHIR server that supports SMART on FHIR. Ensure you have the necessary credentials and configuration. -A good EHR Launcher to test with is: o test with is: [SMART Health IT Sandbox](https://launch.smarthealthit.org/). Some of the steps you would need to take are: -- Create a new EHR client in the sandbox -- Add the redirect URL -- Add the client ID and secret -- Add the scopes -- Set the same client ID and secret in the application +The project includes unit tests for database logic and FHIR parsing. + +```bash +go test ./... +``` + +## Future Improvements + +- [ ] Support for Observations, Conditions, and Encounters. +- [ ] Move configuration to a YAML/TOML file. +- [ ] Add structured logging (slog). +- [ ] Implement Refresh Token handling. diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000..2e18fc6 --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,80 @@ +// Package config defines the application configuration types. +// +// The AppConfig struct is the single source of truth for all runtime +// settings. Currently values are populated programmatically in main.go, +// but the structure is designed so that a future loader (e.g., from a +// TOML/YAML file or environment variables) can populate it without +// changing how the rest of the codebase consumes configuration. +package config + +// AppConfig is the root configuration for the platform. +type AppConfig struct { + // Server holds HTTP server settings. + Server ServerConfig + + // SMART holds the platform's own OAuth2 identity. + SMART SMARTConfig + + // EHRs is the list of registered EHR FHIR server configurations. + EHRs []EHRConfig + + // DBPath is the file path for the SQLite database. + // Use ":memory:" for in-process testing. + DBPath string +} + +// ServerConfig holds HTTP server settings. +type ServerConfig struct { + // Port is the port the HTTP server listens on. + Port int +} + +// SMARTConfig holds the platform's own SMART on FHIR OAuth2 identity. +type SMARTConfig struct { + // RedirectURL is the full URL for the OAuth2 callback endpoint, + // e.g. "http://localhost:8080/auth-redirect". + RedirectURL string + + // Scopes is the list of OAuth2 scopes requested during authorization. + // Standard SMART scopes: launch, openid, profile, patient/*.read, etc. + Scopes []string +} + +// EHRConfig describes a registered EHR FHIR server. +type EHRConfig struct { + // Name is a human-readable label for this EHR (e.g., "SmartHealthIT Sandbox"). + Name string + + // FHIRURL is the FHIR server base URL used as the `iss` parameter. + // This is the canonical identifier for matching incoming launch requests. + FHIRURL string + + // ClientID is the OAuth2 client_id registered with this EHR. + ClientID string + + // ClientSecret is the OAuth2 client_secret registered with this EHR. + // In production this should be loaded from a secrets manager, not + // hardcoded in source. + ClientSecret string +} + +// EHRByURL returns the EHRConfig whose FHIRURL matches the given URL, +// normalising trailing slashes for comparison. +// Returns nil if no match is found. +func (c *AppConfig) EHRByURL(url string) *EHRConfig { + // Trim trailing slash for comparison robustness. + url = trimTrailingSlash(url) + for i := range c.EHRs { + if trimTrailingSlash(c.EHRs[i].FHIRURL) == url { + return &c.EHRs[i] + } + } + return nil +} + +func trimTrailingSlash(s string) string { + if len(s) > 0 && s[len(s)-1] == '/' { + return s[:len(s)-1] + } + return s +} diff --git a/app/db/clinical.go b/app/db/clinical.go new file mode 100644 index 0000000..634ac94 --- /dev/null +++ b/app/db/clinical.go @@ -0,0 +1,340 @@ +// clinical.go provides persistence operations for FHIR clinical resources: +// Observation, Condition, DocumentReference, and PatientSync. +// +// All upsert methods use (fhir_id, ehr_url) as the natural deduplication key, +// matching the same pattern used for users. On conflict the stored row is +// overwritten with the latest data from the EHR and synced_at is updated. +package db + +import ( + "fmt" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" + "github.com/google/uuid" +) + +// --------------------------------------------------------------------------- +// Observation +// --------------------------------------------------------------------------- + +// UpsertObservation inserts or replaces an Observation record keyed on +// (fhir_id, ehr_url). Returns the internal UUID. +func (s *Store) UpsertObservation(o *models.Observation) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM observations WHERE fhir_id = ? AND ehr_url = ?`, + o.FHIRID, o.EHRURL, + ).Scan(&existingID) + + 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 = ? + WHERE id = ?`, + o.PatientFHIRID, o.Status, o.Category, + o.CodeText, o.CodeSystem, o.CodeCode, + o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update observation %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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, + ) + if err != nil { + return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err) + } + return id, nil +} + +// ListObservations returns all Observations for the given patient, newest first. +func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observation, error) { + 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 + FROM observations + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY effective_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list observations: %w", err) + } + defer rows.Close() + + var out []models.Observation + for rows.Next() { + var o models.Observation + 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, + ); err != nil { + return nil, fmt.Errorf("db: scan observation: %w", err) + } + out = append(out, o) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Condition +// --------------------------------------------------------------------------- + +// UpsertCondition inserts or updates a Condition record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertCondition(c *models.Condition) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM conditions WHERE fhir_id = ? AND ehr_url = ?`, + c.FHIRID, c.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE conditions SET + patient_fhir_id = ?, + clinical_status = ?, + verification_status = ?, + category = ?, + code_text = ?, + code_system = ?, + code_code = ?, + onset_date = ?, + recorded_date = ?, + synced_at = ? + WHERE id = ?`, + c.PatientFHIRID, c.ClinicalStatus, c.VerificationStatus, + c.Category, c.CodeText, c.CodeSystem, c.CodeCode, + c.OnsetDate, c.RecordedDate, now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update condition %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO conditions ( + id, fhir_id, ehr_url, patient_fhir_id, + clinical_status, verification_status, category, + code_text, code_system, code_code, + onset_date, recorded_date, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, c.FHIRID, c.EHRURL, c.PatientFHIRID, + c.ClinicalStatus, c.VerificationStatus, c.Category, + c.CodeText, c.CodeSystem, c.CodeCode, + c.OnsetDate, c.RecordedDate, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert condition fhir_id=%s: %w", c.FHIRID, err) + } + return id, nil +} + +// ListConditions returns all Conditions for the given patient, newest first. +func (s *Store) ListConditions(patientFHIRID, ehrURL string) ([]models.Condition, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + clinical_status, verification_status, category, + code_text, code_system, code_code, + onset_date, recorded_date, synced_at + FROM conditions + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY recorded_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list conditions: %w", err) + } + defer rows.Close() + + var out []models.Condition + for rows.Next() { + var c models.Condition + if err := rows.Scan( + &c.ID, &c.FHIRID, &c.EHRURL, &c.PatientFHIRID, + &c.ClinicalStatus, &c.VerificationStatus, &c.Category, + &c.CodeText, &c.CodeSystem, &c.CodeCode, + &c.OnsetDate, &c.RecordedDate, &c.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan condition: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// DocumentReference +// --------------------------------------------------------------------------- + +// UpsertDocumentReference inserts or updates a DocumentReference record. +func (s *Store) UpsertDocumentReference(d *models.DocumentReference) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM document_references WHERE fhir_id = ? AND ehr_url = ?`, + d.FHIRID, d.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE document_references SET + patient_fhir_id = ?, + status = ?, + doc_status = ?, + type_text = ?, + type_system = ?, + type_code = ?, + category = ?, + date = ?, + description = ?, + content_type = ?, + content_url = ?, + content_data = ?, + synced_at = ? + WHERE id = ?`, + d.PatientFHIRID, d.Status, d.DocStatus, + d.TypeText, d.TypeSystem, d.TypeCode, + d.Category, d.Date, d.Description, + d.ContentType, d.ContentURL, d.ContentData, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update document_reference %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO document_references ( + id, fhir_id, ehr_url, patient_fhir_id, + status, doc_status, type_text, type_system, type_code, + category, date, description, + content_type, content_url, content_data, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, d.FHIRID, d.EHRURL, d.PatientFHIRID, + d.Status, d.DocStatus, d.TypeText, d.TypeSystem, d.TypeCode, + d.Category, d.Date, d.Description, + d.ContentType, d.ContentURL, d.ContentData, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert document_reference fhir_id=%s: %w", d.FHIRID, err) + } + return id, nil +} + +// ListDocumentReferences returns all DocumentReferences for the given patient, +// newest first. +func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.DocumentReference, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, doc_status, type_text, type_system, type_code, + category, date, description, + content_type, content_url, content_data, synced_at + FROM document_references + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list document_references: %w", err) + } + defer rows.Close() + + var out []models.DocumentReference + for rows.Next() { + var d models.DocumentReference + if err := rows.Scan( + &d.ID, &d.FHIRID, &d.EHRURL, &d.PatientFHIRID, + &d.Status, &d.DocStatus, &d.TypeText, &d.TypeSystem, &d.TypeCode, + &d.Category, &d.Date, &d.Description, + &d.ContentType, &d.ContentURL, &d.ContentData, &d.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan document_reference: %w", err) + } + out = append(out, d) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// PatientSync +// --------------------------------------------------------------------------- + +// RecordSync inserts a new sync event for a patient. +func (s *Store) RecordSync(patientFHIRID, ehrURL string, obsCount, condCount, docCount int) (*models.PatientSync, error) { + ps := &models.PatientSync{ + ID: uuid.NewString(), + PatientFHIRID: patientFHIRID, + EHRURL: ehrURL, + SyncedAt: time.Now().UTC(), + ObsCount: obsCount, + CondCount: condCount, + DocCount: docCount, + } + _, err := s.db.Exec(` + INSERT INTO patient_syncs (id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ps.ID, ps.PatientFHIRID, ps.EHRURL, ps.SyncedAt, ps.ObsCount, ps.CondCount, ps.DocCount, + ) + if err != nil { + return nil, fmt.Errorf("db: record sync for patient %s: %w", patientFHIRID, err) + } + return ps, nil +} + +// LatestSync retrieves the most recent sync event for a patient. +// Returns nil, nil when no sync has ever been performed. +func (s *Store) LatestSync(patientFHIRID, ehrURL string) (*models.PatientSync, error) { + ps := &models.PatientSync{} + err := s.db.QueryRow(` + SELECT id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count + FROM patient_syncs + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY synced_at DESC + LIMIT 1`, + patientFHIRID, ehrURL, + ).Scan( + &ps.ID, &ps.PatientFHIRID, &ps.EHRURL, &ps.SyncedAt, + &ps.ObsCount, &ps.CondCount, &ps.DocCount, + ) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, fmt.Errorf("db: latest sync for patient %s: %w", patientFHIRID, err) + } + return ps, nil +} diff --git a/app/db/clinical_test.go b/app/db/clinical_test.go new file mode 100644 index 0000000..a253def --- /dev/null +++ b/app/db/clinical_test.go @@ -0,0 +1,345 @@ +package db_test + +// clinical_test.go — tests for clinical resource persistence (Observation, +// Condition, DocumentReference, PatientSync). + +import ( + "testing" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +const ( + testEHRURL = "https://ehr.example.com/fhir" + testPatientID = "patient-clinical-001" +) + +// --------------------------------------------------------------------------- +// Observation tests +// --------------------------------------------------------------------------- + +func TestUpsertObservation_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + qty := 98.6 + obs := &models.Observation{ + FHIRID: "obs-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "final", + Category: "vital-signs", + CodeText: "Body Temperature", + CodeSystem: "http://loinc.org", + CodeCode: "8310-5", + EffectiveDate: "2024-01-15", + ValueQuantity: &qty, + ValueUnit: "°F", + } + + id1, err := store.UpsertObservation(obs) + if err != nil { + t.Fatalf("initial UpsertObservation: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + // Update: change value and status. + newQty := 99.1 + obs.ValueQuantity = &newQty + obs.Status = "amended" + id2, err := store.UpsertObservation(obs) + if err != nil { + t.Fatalf("update UpsertObservation: %v", err) + } + + // ID must be stable across upserts. + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + // Read back and confirm updated fields. + rows, err := store.ListObservations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListObservations: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 observation, got %d", len(rows)) + } + got := rows[0] + if got.Status != "amended" { + t.Errorf("Status: got %q, want %q", got.Status, "amended") + } + if got.ValueQuantity == nil || *got.ValueQuantity != 99.1 { + t.Errorf("ValueQuantity: got %v, want 99.1", got.ValueQuantity) + } +} + +func TestUpsertObservation_TenantIsolation(t *testing.T) { + store := newTestStore(t) + + makeObs := func(ehrURL string) *models.Observation { + return &models.Observation{ + FHIRID: "obs-shared", + EHRURL: ehrURL, + PatientFHIRID: testPatientID, + Status: "final", + } + } + + id1, err := store.UpsertObservation(makeObs("https://ehr-a.example.com/fhir")) + if err != nil { + t.Fatalf("upsert A: %v", err) + } + id2, err := store.UpsertObservation(makeObs("https://ehr-b.example.com/fhir")) + if err != nil { + t.Fatalf("upsert B: %v", err) + } + if id1 == id2 { + t.Error("expected different IDs for same fhir_id at different ehr_urls") + } +} + +func TestListObservations_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListObservations("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListObservations: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0 rows, got %d", len(rows)) + } +} + +func TestListObservations_OrderedNewestFirst(t *testing.T) { + store := newTestStore(t) + + for _, item := range []struct { + id string + date string + }{ + {"obs-a", "2024-01-01"}, + {"obs-b", "2024-06-15"}, + {"obs-c", "2023-12-31"}, + } { + _, err := store.UpsertObservation(&models.Observation{ + FHIRID: item.id, + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "final", + EffectiveDate: item.date, + }) + if err != nil { + t.Fatalf("UpsertObservation %s: %v", item.id, err) + } + } + + rows, err := store.ListObservations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListObservations: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + // Newest first: obs-b (Jun) > obs-a (Jan) > obs-c (Dec 2023) + if rows[0].FHIRID != "obs-b" { + t.Errorf("first row: got %q, want obs-b", rows[0].FHIRID) + } + if rows[2].FHIRID != "obs-c" { + t.Errorf("last row: got %q, want obs-c", rows[2].FHIRID) + } +} + +// --------------------------------------------------------------------------- +// Condition tests +// --------------------------------------------------------------------------- + +func TestUpsertCondition_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + cond := &models.Condition{ + FHIRID: "cond-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + ClinicalStatus: "active", + VerificationStatus: "confirmed", + Category: "problem-list-item", + CodeText: "Hypertension", + CodeSystem: "http://snomed.info/sct", + CodeCode: "38341003", + OnsetDate: "2020-03-01", + RecordedDate: "2020-03-05", + } + + id1, err := store.UpsertCondition(cond) + if err != nil { + t.Fatalf("initial UpsertCondition: %v", err) + } + + // Update clinical status to resolved. + cond.ClinicalStatus = "resolved" + id2, err := store.UpsertCondition(cond) + if err != nil { + t.Fatalf("update UpsertCondition: %v", err) + } + + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListConditions(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListConditions: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 condition, got %d", len(rows)) + } + if rows[0].ClinicalStatus != "resolved" { + t.Errorf("ClinicalStatus: got %q, want resolved", rows[0].ClinicalStatus) + } +} + +func TestListConditions_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListConditions("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListConditions: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// DocumentReference tests +// --------------------------------------------------------------------------- + +func TestUpsertDocumentReference_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + doc := &models.DocumentReference{ + FHIRID: "doc-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "current", + DocStatus: "final", + TypeText: "Discharge Summary", + TypeSystem: "http://loinc.org", + TypeCode: "18842-5", + Date: "2024-02-10", + Description: "Hospital discharge summary", + ContentType: "text/plain", + ContentURL: "https://ehr.example.com/fhir/Binary/bin-001", + } + + id1, err := store.UpsertDocumentReference(doc) + if err != nil { + t.Fatalf("UpsertDocumentReference: %v", err) + } + + // Supersede the document. + doc.Status = "superseded" + id2, err := store.UpsertDocumentReference(doc) + if err != nil { + t.Fatalf("update UpsertDocumentReference: %v", err) + } + + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListDocumentReferences(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListDocumentReferences: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 doc, got %d", len(rows)) + } + if rows[0].Status != "superseded" { + t.Errorf("Status: got %q, want superseded", rows[0].Status) + } +} + +func TestListDocumentReferences_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListDocumentReferences("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListDocumentReferences: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// PatientSync tests +// --------------------------------------------------------------------------- + +func TestRecordAndLatestSync(t *testing.T) { + store := newTestStore(t) + + // No sync yet. + ps, err := store.LatestSync(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("LatestSync (empty): %v", err) + } + if ps != nil { + t.Errorf("expected nil before any sync, got %+v", ps) + } + + // Record first sync. + recorded, err := store.RecordSync(testPatientID, testEHRURL, 10, 3, 1) + if err != nil { + t.Fatalf("RecordSync: %v", err) + } + if recorded.ID == "" { + t.Fatal("expected non-empty ID") + } + if recorded.ObsCount != 10 || recorded.CondCount != 3 || recorded.DocCount != 1 { + t.Errorf("counts wrong: %+v", recorded) + } + + // LatestSync should now return that record. + latest, err := store.LatestSync(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("LatestSync: %v", err) + } + if latest == nil { + t.Fatal("expected non-nil latest sync") + } + if latest.ID != recorded.ID { + t.Errorf("ID mismatch: got %q, want %q", latest.ID, recorded.ID) + } + + // Record a second sync with higher counts; LatestSync must return the newer one. + _, err = store.RecordSync(testPatientID, testEHRURL, 20, 5, 2) + if err != nil { + t.Fatalf("RecordSync second: %v", err) + } + + latest2, err := store.LatestSync(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("LatestSync second: %v", err) + } + if latest2.ObsCount != 20 { + t.Errorf("expected ObsCount 20 from latest, got %d", latest2.ObsCount) + } +} + +func TestLatestSync_TenantIsolation(t *testing.T) { + store := newTestStore(t) + + _, err := store.RecordSync(testPatientID, "https://ehr-a.example.com/fhir", 5, 1, 0) + if err != nil { + t.Fatalf("RecordSync EHR-A: %v", err) + } + + // Querying for a different EHR URL should return nil. + ps, err := store.LatestSync(testPatientID, "https://ehr-b.example.com/fhir") + if err != nil { + t.Fatalf("LatestSync EHR-B: %v", err) + } + if ps != nil { + t.Errorf("expected nil for different EHR URL, got %+v", ps) + } +} diff --git a/app/db/db.go b/app/db/db.go new file mode 100644 index 0000000..4c24c5f --- /dev/null +++ b/app/db/db.go @@ -0,0 +1,421 @@ +// Package db provides the SQLite-backed persistence layer for the platform. +// +// Architecture notes: +// - Uses the standard database/sql interface with a pure-Go SQLite driver +// (modernc.org/sqlite), requiring no CGO. +// - Schema versioning is handled via the schema_migrations table, enabling +// forward-only incremental migrations as the platform grows. +// - All public functions accept a *Store receiver, keeping the DB handle +// encapsulated and allowing easy substitution (e.g., for testing with +// an in-memory SQLite instance). +// - Upsert semantics for users use (fhir_id, ehr_url) as the natural key, +// preventing duplicates across EHR tenants while allowing the same FHIR +// ID to exist at different servers. +package db + +import ( + "database/sql" + "fmt" + "log" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" + "github.com/google/uuid" + _ "modernc.org/sqlite" // Register the sqlite driver under the name "sqlite". +) + +// Store wraps a database/sql.DB and exposes all persistence operations +// for the platform. It is the single point of DB access — no raw *sql.DB +// handles should escape this package. +type Store struct { + db *sql.DB +} + +// New opens (or creates) the SQLite database at the given path, applies +// all pending migrations, and returns a ready-to-use Store. +// +// Use path ":memory:" in tests to get a disposable in-memory database. +func New(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("db: open: %w", err) + } + + // SQLite is file-based; a small pool is sufficient. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("db: migrate: %w", err) + } + return s, nil +} + +// Close releases the underlying database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +// --------------------------------------------------------------------------- +// Schema migrations +// --------------------------------------------------------------------------- + +// migration represents a single, versioned, forward-only DDL statement. +// New tables and columns must be added as new migrations — never alter +// existing ones, to preserve upgrade safety. +type migration struct { + version int + sql string +} + +var migrations = []migration{ + { + version: 1, + sql: ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + fhir_resource_type TEXT NOT NULL, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + role TEXT NOT NULL, + first_name TEXT NOT NULL DEFAULT '', + middle_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + dob TEXT NOT NULL DEFAULT '', + gender TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token TEXT NOT NULL DEFAULT '', + ehr_url TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_users_fhir ON users(fhir_id, ehr_url); + `, + }, + { + version: 2, + sql: ` + ALTER TABLE sessions ADD COLUMN patient_fhir_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE sessions ADD COLUMN id_token TEXT NOT NULL DEFAULT ''; + ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT ''; + `, + }, + { + version: 3, + sql: ` + CREATE TABLE IF NOT EXISTS observations ( + 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 '', + category TEXT NOT NULL DEFAULT '', + code_text TEXT NOT NULL DEFAULT '', + code_system TEXT NOT NULL DEFAULT '', + code_code TEXT NOT NULL DEFAULT '', + effective_date TEXT NOT NULL DEFAULT '', + value_quantity REAL, + value_unit TEXT NOT NULL DEFAULT '', + value_string TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE TABLE IF NOT EXISTS conditions ( + 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 '', + category TEXT NOT NULL DEFAULT '', + code_text TEXT NOT NULL DEFAULT '', + code_system TEXT NOT NULL DEFAULT '', + code_code TEXT NOT NULL DEFAULT '', + onset_date TEXT NOT NULL DEFAULT '', + recorded_date TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE TABLE IF NOT EXISTS document_references ( + 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 '', + doc_status TEXT NOT NULL DEFAULT '', + type_text TEXT NOT NULL DEFAULT '', + type_system TEXT NOT NULL DEFAULT '', + type_code TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT '', + content_url TEXT NOT NULL DEFAULT '', + content_data TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + + CREATE TABLE IF NOT EXISTS patient_syncs ( + id TEXT PRIMARY KEY, + patient_fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + obs_count INTEGER NOT NULL DEFAULT 0, + cond_count INTEGER NOT NULL DEFAULT 0, + doc_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_observations_patient ON observations(patient_fhir_id, ehr_url); + CREATE INDEX IF NOT EXISTS idx_conditions_patient ON conditions(patient_fhir_id, ehr_url); + CREATE INDEX IF NOT EXISTS idx_docrefs_patient ON document_references(patient_fhir_id, ehr_url); + CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url); + `, + }, + // Future migrations: append new entries here with incrementing version numbers. + // Example: + // { + // version: 2, + // sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`, + // }, +} + +// migrate applies any migrations that have not yet been run, in order. +func (s *Store) migrate() error { + // Enable WAL mode for better concurrent read performance. + if _, err := s.db.Exec(`PRAGMA journal_mode=WAL;`); err != nil { + return fmt.Errorf("set WAL mode: %w", err) + } + // Enable foreign key enforcement (off by default in SQLite). + if _, err := s.db.Exec(`PRAGMA foreign_keys=ON;`); err != nil { + return fmt.Errorf("enable foreign keys: %w", err) + } + + // Bootstrap the migrations table if it doesn't exist yet. + if _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + );`); err != nil { + return fmt.Errorf("bootstrap schema_migrations: %w", err) + } + + for _, m := range migrations { + var count int + row := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version) + if err := row.Scan(&count); err != nil { + return fmt.Errorf("check migration v%d: %w", m.version, err) + } + if count > 0 { + continue // Already applied. + } + + if _, err := s.db.Exec(m.sql); err != nil { + return fmt.Errorf("apply migration v%d: %w", m.version, err) + } + if _, err := s.db.Exec(`INSERT INTO schema_migrations(version) VALUES(?)`, m.version); err != nil { + return fmt.Errorf("record migration v%d: %w", m.version, err) + } + log.Printf("db: applied migration v%d", m.version) + } + return nil +} + +// --------------------------------------------------------------------------- +// User operations +// --------------------------------------------------------------------------- + +// UpsertUser inserts a new user or updates the demographics of an existing one, +// matched on the (fhir_id, ehr_url) natural key. +// +// Returns the user's internal UUID (which may be the existing one if the user +// already existed). +func (s *Store) UpsertUser(u *models.User) (string, error) { + now := time.Now().UTC() + + // Check if the user already exists to preserve the original created_at + // and internal ID. + var existingID string + err := s.db.QueryRow( + `SELECT id FROM users WHERE fhir_id = ? AND ehr_url = ?`, + u.FHIRID, u.EHRURL, + ).Scan(&existingID) + + if err == nil { + // User exists — update demographics but preserve ID and created_at. + _, err = s.db.Exec(` + UPDATE users SET + first_name = ?, + middle_name = ?, + last_name = ?, + dob = ?, + gender = ?, + email = ?, + fhir_resource_type = ?, + role = ?, + updated_at = ? + WHERE id = ?`, + u.FirstName, u.MiddleName, u.LastName, + u.DOB, u.Gender, u.Email, + u.FHIRResourceType, string(u.Role), + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update user %s: %w", existingID, err) + } + return existingID, nil + } + + if err != sql.ErrNoRows { + return "", fmt.Errorf("db: lookup user (%s, %s): %w", u.FHIRID, u.EHRURL, err) + } + + // New user — generate a fresh internal UUID. + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO users ( + id, fhir_resource_type, fhir_id, ehr_url, role, + first_name, middle_name, last_name, dob, gender, email, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role), + u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email, + now, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert user (fhir_id=%s): %w", u.FHIRID, err) + } + return id, nil +} + +// GetUserByFHIRID retrieves a user by their FHIR ID and originating EHR URL. +// Returns sql.ErrNoRows if no matching user is found. +func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) { + u := &models.User{} + err := s.db.QueryRow(` + 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 fhir_id = ? AND ehr_url = ?`, + fhirID, ehrURL, + ).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, + ) + if err != nil { + return nil, err + } + return u, nil +} + +// GetUserByID retrieves a user by their internal UUID primary key. +func (s *Store) GetUserByID(id string) (*models.User, error) { + u := &models.User{} + err := s.db.QueryRow(` + 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 id = ?`, id, + ).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, + ) + if err != nil { + return nil, err + } + return u, nil +} + +// --------------------------------------------------------------------------- +// Session operations +// --------------------------------------------------------------------------- + +// CreateSession creates a new authenticated session for the given user, +// storing the FHIR access token and EHR context. Sessions expire after +// the provided duration from now. +func (s *Store) CreateSession(userID, patientFHIRID, accessToken, idToken, scope, ehrURL string, ttl time.Duration) (*models.Session, error) { + now := time.Now().UTC() + sess := &models.Session{ + ID: uuid.NewString(), + UserID: userID, + PatientFHIRID: patientFHIRID, + AccessToken: accessToken, + IDToken: idToken, + Scope: scope, + EHRURL: ehrURL, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + + _, err := s.db.Exec(` + INSERT INTO sessions (id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sess.ID, sess.UserID, sess.PatientFHIRID, sess.AccessToken, sess.IDToken, sess.Scope, sess.EHRURL, + sess.CreatedAt, sess.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("db: create session for user %s: %w", userID, err) + } + return sess, nil +} + +// GetSession retrieves a session by its token ID. +// Returns sql.ErrNoRows if the session does not exist. +func (s *Store) GetSession(id string) (*models.Session, error) { + sess := &models.Session{} + err := s.db.QueryRow(` + SELECT id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at + FROM sessions WHERE id = ?`, id, + ).Scan( + &sess.ID, &sess.UserID, &sess.PatientFHIRID, &sess.AccessToken, &sess.IDToken, &sess.Scope, &sess.EHRURL, + &sess.CreatedAt, &sess.ExpiresAt, + ) + if err != nil { + return nil, err + } + return sess, nil +} + +// DeleteSession removes a session by its token ID. Used during logout. +func (s *Store) DeleteSession(id string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("db: delete session %s: %w", id, err) + } + return nil +} + +// DeleteExpiredSessions removes all sessions whose expiry time has passed. +// This should be called periodically (e.g., on startup or via a background +// goroutine) to keep the sessions table lean. +func (s *Store) DeleteExpiredSessions() (int64, error) { + res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC()) + if err != nil { + return 0, fmt.Errorf("db: purge expired sessions: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} diff --git a/app/db/db_test.go b/app/db/db_test.go new file mode 100644 index 0000000..fc8ea2f --- /dev/null +++ b/app/db/db_test.go @@ -0,0 +1,256 @@ +package db_test + +import ( + "database/sql" + "testing" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// newTestStore creates a disposable in-memory SQLite store for testing. +func newTestStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.New(":memory:") + if err != nil { + t.Fatalf("newTestStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +// --------------------------------------------------------------------------- +// User tests +// --------------------------------------------------------------------------- + +func TestUpsertUser_NewUser(t *testing.T) { + store := newTestStore(t) + + u := &models.User{ + FHIRResourceType: "Patient", + FHIRID: "patient-abc", + EHRURL: "https://ehr.example.com/fhir", + Role: models.RolePatient, + FirstName: "Alice", + MiddleName: "Marie", + LastName: "Smith", + DOB: "1990-04-22", + Gender: "female", + Email: "alice@example.com", + } + + id, err := store.UpsertUser(u) + if err != nil { + t.Fatalf("UpsertUser: %v", err) + } + if id == "" { + t.Fatal("expected non-empty ID for new user") + } + + // Fetch back and verify. + got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL) + if err != nil { + t.Fatalf("GetUserByFHIRID: %v", err) + } + if got.FirstName != "Alice" { + t.Errorf("FirstName: got %q, want %q", got.FirstName, "Alice") + } + if got.Role != models.RolePatient { + t.Errorf("Role: got %q, want %q", got.Role, models.RolePatient) + } + if got.Email != "alice@example.com" { + t.Errorf("Email: got %q, want %q", got.Email, "alice@example.com") + } +} + +func TestUpsertUser_UpdateExisting(t *testing.T) { + store := newTestStore(t) + + u := &models.User{ + FHIRResourceType: "Patient", + FHIRID: "patient-xyz", + EHRURL: "https://ehr.example.com/fhir", + Role: models.RolePatient, + FirstName: "Bob", + LastName: "Jones", + DOB: "1985-01-01", + Gender: "male", + } + + id1, err := store.UpsertUser(u) + if err != nil { + t.Fatalf("initial UpsertUser: %v", err) + } + + // Update demographics. + u.Email = "bob.updated@example.com" + u.FirstName = "Robert" + id2, err := store.UpsertUser(u) + if err != nil { + t.Fatalf("update UpsertUser: %v", err) + } + + // Internal ID must remain stable across upserts. + if id1 != id2 { + t.Errorf("ID changed on upsert: got %q, was %q", id2, id1) + } + + got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL) + if err != nil { + t.Fatalf("GetUserByFHIRID after update: %v", err) + } + if got.FirstName != "Robert" { + t.Errorf("updated FirstName: got %q, want %q", got.FirstName, "Robert") + } + if got.Email != "bob.updated@example.com" { + t.Errorf("updated Email: got %q, want %q", got.Email, "bob.updated@example.com") + } +} + +func TestGetUserByFHIRID_NotFound(t *testing.T) { + store := newTestStore(t) + _, err := store.GetUserByFHIRID("nonexistent", "https://ehr.example.com/fhir") + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestUpsertUser_TenantIsolation(t *testing.T) { + // The same FHIR ID at two different EHR URLs must produce two separate records. + store := newTestStore(t) + + makeUser := func(ehrURL string) *models.User { + return &models.User{ + FHIRResourceType: "Patient", + FHIRID: "shared-fhir-id", + EHRURL: ehrURL, + Role: models.RolePatient, + FirstName: "Carol", + LastName: "Tenant", + } + } + + id1, err := store.UpsertUser(makeUser("https://ehr-a.example.com/fhir")) + if err != nil { + t.Fatalf("upsert EHR-A: %v", err) + } + id2, err := store.UpsertUser(makeUser("https://ehr-b.example.com/fhir")) + if err != nil { + t.Fatalf("upsert EHR-B: %v", err) + } + + if id1 == id2 { + t.Error("expected separate internal IDs for same FHIR ID at different EHR URLs") + } +} + +// --------------------------------------------------------------------------- +// Session tests +// --------------------------------------------------------------------------- + +func TestCreateAndGetSession(t *testing.T) { + store := newTestStore(t) + + // Create a practitioner user first. + practUser := &models.User{ + FHIRResourceType: "Practitioner", + FHIRID: "pract-001", + EHRURL: "https://ehr.example.com/fhir", + Role: models.RolePractitioner, + FirstName: "Dr. Emily", + LastName: "Chen", + } + userID, err := store.UpsertUser(practUser) + if err != nil { + t.Fatalf("UpsertUser practitioner: %v", err) + } + + // Create a session. + sess, err := store.CreateSession(userID, "patient-001", "access-token-xyz", "id-token-abc", "openid profile", "https://ehr.example.com/fhir", 8*time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if sess.ID == "" { + t.Fatal("expected non-empty session ID") + } + + // Retrieve the session. + got, err := store.GetSession(sess.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.UserID != userID { + t.Errorf("UserID: got %q, want %q", got.UserID, userID) + } + if got.AccessToken != "access-token-xyz" { + t.Errorf("AccessToken: got %q, want %q", got.AccessToken, "access-token-xyz") + } + if got.ExpiresAt.Before(time.Now()) { + t.Error("session should not be expired immediately after creation") + } +} + +func TestDeleteSession(t *testing.T) { + store := newTestStore(t) + + userID, err := store.UpsertUser(&models.User{ + FHIRResourceType: "Practitioner", + FHIRID: "pract-delete", + EHRURL: "https://ehr.example.com/fhir", + Role: models.RolePractitioner, + FirstName: "Test", + LastName: "Delete", + }) + if err != nil { + t.Fatalf("UpsertUser: %v", err) + } + + sess, err := store.CreateSession(userID, "pat", "tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + if err := store.DeleteSession(sess.ID); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + _, err = store.GetSession(sess.ID) + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows after deletion, got %v", err) + } +} + +func TestDeleteExpiredSessions(t *testing.T) { + store := newTestStore(t) + + userID, err := store.UpsertUser(&models.User{ + FHIRResourceType: "Practitioner", + FHIRID: "pract-expire", + EHRURL: "https://ehr.example.com/fhir", + Role: models.RolePractitioner, + FirstName: "Expire", + LastName: "Test", + }) + if err != nil { + t.Fatalf("UpsertUser: %v", err) + } + + // Create one valid and one already-expired session. + _, err = store.CreateSession(userID, "pat", "valid-tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour) + if err != nil { + t.Fatalf("CreateSession (valid): %v", err) + } + _, err = store.CreateSession(userID, "pat", "expired-tok", "id", "scope", "https://ehr.example.com/fhir", -1*time.Second) // already past + if err != nil { + t.Fatalf("CreateSession (expired): %v", err) + } + + n, err := store.DeleteExpiredSessions() + if err != nil { + t.Fatalf("DeleteExpiredSessions: %v", err) + } + if n != 1 { + t.Errorf("deleted %d expired sessions, want 1", n) + } +} diff --git a/app/fhir/fhir.go b/app/fhir/fhir.go new file mode 100644 index 0000000..95c7da1 --- /dev/null +++ b/app/fhir/fhir.go @@ -0,0 +1,651 @@ +// Package fhir provides typed representations of FHIR R4 resources and +// utilities for extracting platform-domain data from raw FHIR JSON payloads. +// +// Architecture notes: +// - FHIR resources are received from EHR servers as JSON and decoded into +// typed Go structs defined here. This gives us compile-time safety and +// makes it straightforward to add support for new resource types. +// - The Resource interface is the root of all FHIR types in this package. +// Any new resource (Observation, Condition, Encounter, etc.) should +// implement it so it can be handled generically by shared code. +// - Extraction helpers (ExtractUserFromPatient, ExtractUserFromPractitioner) +// translate FHIR types into the platform's models.User, decoupling the +// FHIR representation from the persistence layer. +// - The Client type wraps http.Client and provides typed FHIR API methods. +// Extend it with new methods (GetObservations, GetConditions, etc.) as +// the platform grows. +package fhir + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// --------------------------------------------------------------------------- +// Core FHIR R4 types +// --------------------------------------------------------------------------- + +// Resource is the base interface for all FHIR R4 resources in this package. +// Every concrete FHIR type must implement ResourceType() returning its +// FHIR resource type string (e.g., "Patient", "Practitioner"). +type Resource interface { + ResourceType() string +} + +// HumanName represents the FHIR HumanName data type (R4). +// https://www.hl7.org/fhir/datatypes.html#HumanName +type HumanName struct { + Use string `json:"use"` + Family string `json:"family"` + Given []string `json:"given"` + Prefix []string `json:"prefix"` + Suffix []string `json:"suffix"` + Text string `json:"text"` +} + +// ContactPoint represents the FHIR ContactPoint data type (R4). +// https://www.hl7.org/fhir/datatypes.html#ContactPoint +type ContactPoint struct { + System string `json:"system"` // phone | fax | email | pager | url | sms | other + Value string `json:"value"` + Use string `json:"use"` // home | work | temp | old | mobile + Rank int `json:"rank"` +} + +// Address represents the FHIR Address data type (R4). +// https://www.hl7.org/fhir/datatypes.html#Address +type Address struct { + Use string `json:"use"` + Type string `json:"type"` + Text string `json:"text"` + Line []string `json:"line"` + City string `json:"city"` + District string `json:"district"` + State string `json:"state"` + PostalCode string `json:"postalCode"` + Country string `json:"country"` +} + +// Coding represents the FHIR Coding data type (R4). +type Coding struct { + System string `json:"system"` + Code string `json:"code"` + Display string `json:"display"` +} + +// CodeableConcept represents the FHIR CodeableConcept data type (R4). +type CodeableConcept struct { + Coding []Coding `json:"coding"` + Text string `json:"text"` +} + +// Reference represents the FHIR Reference data type (R4). +type Reference struct { + Reference string `json:"reference"` + Display string `json:"display"` + Type string `json:"type"` +} + +// Identifier represents the FHIR Identifier data type (R4). +type Identifier struct { + Use string `json:"use"` + Type CodeableConcept `json:"type"` + System string `json:"system"` + Value string `json:"value"` +} + +// Meta represents the FHIR Meta data type (R4). +type Meta struct { + VersionID string `json:"versionId"` + LastUpdated time.Time `json:"lastUpdated"` + Source string `json:"source"` + Profile []string `json:"profile"` +} + +// --------------------------------------------------------------------------- +// Patient resource (R4) +// https://www.hl7.org/fhir/patient.html +// --------------------------------------------------------------------------- + +// Patient represents a FHIR R4 Patient resource. +// Fields are a curated subset of the full specification — add new fields +// here as the platform needs them, without breaking existing code. +type Patient struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Meta Meta `json:"meta"` + Identifier []Identifier `json:"identifier"` + Active bool `json:"active"` + Name []HumanName `json:"name"` + Telecom []ContactPoint `json:"telecom"` + Gender string `json:"gender"` + BirthDate string `json:"birthDate"` + Address []Address `json:"address"` + MaritalStatus CodeableConcept `json:"maritalStatus"` +} + +// ResourceType implements the Resource interface. +func (p *Patient) ResourceType() string { return "Patient" } + +// --------------------------------------------------------------------------- +// Practitioner resource (R4) +// https://www.hl7.org/fhir/practitioner.html +// --------------------------------------------------------------------------- + +// Practitioner represents a FHIR R4 Practitioner resource. +type Practitioner struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Meta Meta `json:"meta"` + Identifier []Identifier `json:"identifier"` + Active bool `json:"active"` + Name []HumanName `json:"name"` + Telecom []ContactPoint `json:"telecom"` + Gender string `json:"gender"` + BirthDate string `json:"birthDate"` + Address []Address `json:"address"` +} + +// ResourceType implements the Resource interface. +func (p *Practitioner) ResourceType() string { return "Practitioner" } + +// --------------------------------------------------------------------------- +// Clinical resources (R4) +// --------------------------------------------------------------------------- + +// 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"` +} + +func (o *Observation) ResourceType() string { return "Observation" } + +// Condition represents a FHIR R4 Condition resource. +// https://www.hl7.org/fhir/condition.html +type Condition struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + ClinicalStatus CodeableConcept `json:"clinicalStatus"` + VerificationStatus CodeableConcept `json:"verificationStatus"` + Category []CodeableConcept `json:"category"` + Code CodeableConcept `json:"code"` + Subject Reference `json:"subject"` + OnsetDateTime string `json:"onsetDateTime"` + RecordedDate string `json:"recordedDate"` +} + +func (c *Condition) ResourceType() string { return "Condition" } + +// Attachment represents the FHIR Attachment data type. +type Attachment struct { + ContentType string `json:"contentType"` + Language string `json:"language"` + Data string `json:"data"` // base64-encoded + URL string `json:"url"` + Title string `json:"title"` + Creation string `json:"creation"` +} + +// DocumentReferenceContent holds a single content item in a DocumentReference. +type DocumentReferenceContent struct { + Attachment Attachment `json:"attachment"` + Format CodeableConcept `json:"format"` +} + +// DocumentReference represents a FHIR R4 DocumentReference resource. +// https://www.hl7.org/fhir/documentreference.html +type DocumentReference struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + DocStatus string `json:"docStatus"` + Type CodeableConcept `json:"type"` + Category []CodeableConcept `json:"category"` + Subject Reference `json:"subject"` + Date string `json:"date"` + Description string `json:"description"` + Content []DocumentReferenceContent `json:"content"` +} + +func (d *DocumentReference) ResourceType() string { return "DocumentReference" } + +// Quantity represents the FHIR Quantity data type. +type Quantity struct { + Value float64 `json:"value"` + Unit string `json:"unit"` + System string `json:"system"` + Code string `json:"code"` +} + +// 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"` + Entry []struct { + FullUrl string `json:"fullUrl"` + Resource json.RawMessage `json:"resource"` + } `json:"entry"` +} + +// --------------------------------------------------------------------------- +// SMART discovery types +// --------------------------------------------------------------------------- + +// SmartConfiguration represents the payload returned by the FHIR server's +// .well-known/smart-configuration endpoint. +// https://build.fhir.org/ig/HL7/smart-app-launch/conformance.html +type SmartConfiguration struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + RegistrationEndpoint string `json:"registration_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + Capabilities []string `json:"capabilities"` +} + +// TokenResponse is the OAuth2 token endpoint response, extended with +// SMART-specific fields. +// https://build.fhir.org/ig/HL7/smart-app-launch/ +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + + // SMART launch context extensions + 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, + // e.g. "Practitioner/52919099-..." as returned by SmartHealthIT and + // defined in the SMART App Launch specification. + User string `json:"user"` + NeedPatientBanner bool `json:"need_patient_banner"` + SmartStyleURL string `json:"smart_style_url"` +} + +// --------------------------------------------------------------------------- +// FHIR API Client +// --------------------------------------------------------------------------- + +// Client is a thin, typed FHIR R4 REST client. It holds an access token +// and the FHIR server base URL so callers don't have to manage headers +// on every request. +// +// To support new resource types: add a method like GetObservations, +// GetConditions, etc., following the pattern of GetPatient / GetPractitioner. +type Client struct { + httpClient *http.Client + baseURL string + accessToken string +} + +// NewClient creates a FHIR API client for the given base URL and Bearer token. +func NewClient(baseURL, accessToken string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + accessToken: accessToken, + } +} + +// get performs an authenticated GET request to the FHIR server and decodes +// the JSON response into dest. +func (c *Client) get(path string, dest interface{}) error { + url := fmt.Sprintf("%s/%s", c.baseURL, strings.TrimLeft(path, "/")) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("fhir: build request for %s: %w", url, err) + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Accept", "application/fhir+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("fhir: GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("fhir: GET %s returned %d: %s", url, resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("fhir: decode response from %s: %w", url, err) + } + return nil +} + +// GetPatient fetches a Patient resource by FHIR ID. +func (c *Client) GetPatient(id string) (*Patient, error) { + var p Patient + if err := c.get(fmt.Sprintf("Patient/%s", id), &p); err != nil { + return nil, err + } + return &p, nil +} + +// GetPractitioner fetches a Practitioner resource by FHIR ID. +func (c *Client) GetPractitioner(id string) (*Practitioner, error) { + var p Practitioner + if err := c.get(fmt.Sprintf("Practitioner/%s", id), &p); err != nil { + return nil, err + } + return &p, nil +} + +// GetObservations fetches Observation resources for a specific patient. +func (c *Client) GetObservations(patientID string) ([]Observation, error) { + var bundle Bundle + path := fmt.Sprintf("Observation?patient=%s&_sort=-date", patientID) + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + var observations []Observation + for _, entry := range bundle.Entry { + var o Observation + if err := json.Unmarshal(entry.Resource, &o); err == nil { + observations = append(observations, o) + } + } + return observations, nil +} + +// GetConditions fetches Condition resources for a specific patient. +func (c *Client) GetConditions(patientID string) ([]Condition, error) { + var bundle Bundle + path := fmt.Sprintf("Condition?patient=%s", patientID) + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + var conditions []Condition + for _, entry := range bundle.Entry { + var cond Condition + if err := json.Unmarshal(entry.Resource, &cond); err == nil { + conditions = append(conditions, cond) + } + } + return conditions, nil +} + +// GetDocumentReferences fetches DocumentReference resources for a specific patient. +// Results are sorted newest-first by date. +func (c *Client) GetDocumentReferences(patientID string) ([]DocumentReference, error) { + var bundle Bundle + path := fmt.Sprintf("DocumentReference?patient=%s&_sort=-date", patientID) + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + var docs []DocumentReference + for _, entry := range bundle.Entry { + var d DocumentReference + if err := json.Unmarshal(entry.Resource, &d); err == nil { + docs = append(docs, d) + } + } + return docs, nil +} + +// GetSmartConfiguration fetches and parses the SMART discovery document +// for this FHIR server. +func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) { + url := fmt.Sprintf("%s/.well-known/smart-configuration", strings.TrimRight(issURL, "/")) + resp, err := http.Get(url) //nolint:noctx // discovery calls do not need request context + if err != nil { + return nil, fmt.Errorf("fhir: GET smart-configuration from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("fhir: smart-configuration %s returned %d: %s", url, resp.StatusCode, string(body)) + } + + var cfg SmartConfiguration + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, fmt.Errorf("fhir: decode smart-configuration: %w", err) + } + return &cfg, nil +} + +// --------------------------------------------------------------------------- +// Domain extraction helpers +// --------------------------------------------------------------------------- + +// primaryName returns the first HumanName with use=="official", falling back +// to the first name in the slice, or an empty HumanName if none exist. +func primaryName(names []HumanName) HumanName { + for _, n := range names { + if n.Use == "official" { + return n + } + } + if len(names) > 0 { + return names[0] + } + return HumanName{} +} + +// primaryEmail returns the first email address from a ContactPoint slice. +func primaryEmail(telecom []ContactPoint) string { + for _, t := range telecom { + if t.System == "email" && t.Value != "" { + return t.Value + } + } + return "" +} + +// ExtractUserFromPatient converts a FHIR Patient resource into a platform +// models.User with Role=RolePatient. The ehrURL is the originating FHIR +// server base URL. +func ExtractUserFromPatient(p *Patient, ehrURL string) *models.User { + name := primaryName(p.Name) + first, middle := "", "" + if len(name.Given) > 0 { + first = name.Given[0] + } + if len(name.Given) > 1 { + middle = name.Given[1] + } + return &models.User{ + FHIRResourceType: "Patient", + FHIRID: p.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + Role: models.RolePatient, + FirstName: first, + MiddleName: middle, + LastName: name.Family, + DOB: p.BirthDate, + Gender: p.Gender, + Email: primaryEmail(p.Telecom), + } +} + +// ExtractUserFromPractitioner converts a FHIR Practitioner resource into a +// platform models.User with Role=RolePractitioner. +func ExtractUserFromPractitioner(p *Practitioner, ehrURL string) *models.User { + name := primaryName(p.Name) + first, middle := "", "" + if len(name.Given) > 0 { + first = name.Given[0] + } + if len(name.Given) > 1 { + middle = name.Given[1] + } + return &models.User{ + FHIRResourceType: "Practitioner", + FHIRID: p.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + Role: models.RolePractitioner, + FirstName: first, + MiddleName: middle, + LastName: name.Family, + DOB: p.BirthDate, + Gender: p.Gender, + Email: primaryEmail(p.Telecom), + } +} + +// --------------------------------------------------------------------------- +// FHIR → domain model extraction helpers +// --------------------------------------------------------------------------- + +// firstCoding returns the first Coding from a CodeableConcept, or zero value. +func firstCoding(cc CodeableConcept) Coding { + if len(cc.Coding) > 0 { + return cc.Coding[0] + } + return Coding{} +} + +// firstCategoryText returns the text (or first coding display) of the first +// element in a []CodeableConcept, e.g. as used for Observation.category. +func firstCategoryText(cats []CodeableConcept) string { + if len(cats) == 0 { + return "" + } + c := cats[0] + if c.Text != "" { + return c.Text + } + if len(c.Coding) > 0 { + if c.Coding[0].Display != "" { + return c.Coding[0].Display + } + return c.Coding[0].Code + } + return "" +} + +// ExtractObservation maps a FHIR Observation to a models.Observation ready +// for upsert. patientFHIRID and ehrURL are injected by the caller because +// they are session-level context, not encoded inside the FHIR resource. +func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Observation { + coding := firstCoding(o.Code) + var qty *float64 + var unit string + if o.ValueQuantity != nil { + v := o.ValueQuantity.Value + qty = &v + unit = o.ValueQuantity.Unit + } + 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, + } +} + +// ExtractCondition maps a FHIR Condition to a models.Condition ready for upsert. +func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condition { + coding := firstCoding(c.Code) + clinicalStatus := firstCoding(c.ClinicalStatus) + verificationStatus := firstCoding(c.VerificationStatus) + return &models.Condition{ + FHIRID: c.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + ClinicalStatus: clinicalStatus.Code, + VerificationStatus: verificationStatus.Code, + Category: firstCategoryText(c.Category), + CodeText: c.Code.Text, + CodeSystem: coding.System, + CodeCode: coding.Code, + OnsetDate: c.OnsetDateTime, + RecordedDate: c.RecordedDate, + } +} + +// ExtractDocumentReference maps a FHIR DocumentReference to a +// models.DocumentReference ready for upsert. Only the first content item is +// persisted; additional content attachments are not common in practice. +func ExtractDocumentReference(d *DocumentReference, patientFHIRID, ehrURL string) *models.DocumentReference { + coding := firstCoding(d.Type) + category := firstCategoryText(d.Category) + + var contentType, contentURL, contentData string + if len(d.Content) > 0 { + att := d.Content[0].Attachment + contentType = att.ContentType + contentURL = att.URL + contentData = att.Data + } + + return &models.DocumentReference{ + FHIRID: d.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: d.Status, + DocStatus: d.DocStatus, + TypeText: d.Type.Text, + TypeSystem: coding.System, + TypeCode: coding.Code, + Category: category, + Date: d.Date, + Description: d.Description, + ContentType: contentType, + ContentURL: contentURL, + ContentData: contentData, + } +} + +// 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. +func ParseFHIRUserFromIDToken(idToken string) string { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "" + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + + var claims struct { + FHIRUser string `json:"fhirUser"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + + return claims.FHIRUser +} diff --git a/app/fhir/fhir_test.go b/app/fhir/fhir_test.go new file mode 100644 index 0000000..ad8171c --- /dev/null +++ b/app/fhir/fhir_test.go @@ -0,0 +1,131 @@ +package fhir_test + +import ( + "testing" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// --------------------------------------------------------------------------- +// ExtractUserFromPatient tests +// --------------------------------------------------------------------------- + +func TestExtractUserFromPatient_FullRecord(t *testing.T) { + p := &fhir.Patient{ + ResourceTypeField: "Patient", + ID: "patient-001", + Gender: "female", + BirthDate: "1990-04-22", + Name: []fhir.HumanName{ + {Use: "official", Family: "Smith", Given: []string{"Alice", "Marie"}}, + }, + Telecom: []fhir.ContactPoint{ + {System: "phone", Value: "+15555550100"}, + {System: "email", Value: "alice@example.com"}, + }, + } + + u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "patient-001", u.FHIRID) + assertEqual(t, "Role", string(models.RolePatient), string(u.Role)) + assertEqual(t, "FHIRResourceType", "Patient", u.FHIRResourceType) + assertEqual(t, "FirstName", "Alice", u.FirstName) + assertEqual(t, "MiddleName", "Marie", u.MiddleName) + assertEqual(t, "LastName", "Smith", u.LastName) + assertEqual(t, "DOB", "1990-04-22", u.DOB) + assertEqual(t, "Gender", "female", u.Gender) + assertEqual(t, "Email", "alice@example.com", u.Email) + assertEqual(t, "EHRURL", "https://ehr.example.com/fhir", u.EHRURL) +} + +func TestExtractUserFromPatient_NoMiddleName(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-002", + Gender: "male", + Name: []fhir.HumanName{{Use: "official", Family: "Jones", Given: []string{"Bob"}}}, + } + + u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir") + + assertEqual(t, "FirstName", "Bob", u.FirstName) + assertEqual(t, "MiddleName", "", u.MiddleName) + assertEqual(t, "LastName", "Jones", u.LastName) +} + +func TestExtractUserFromPatient_NoEmail(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-003", + Telecom: []fhir.ContactPoint{{System: "phone", Value: "+15555559999"}}, + } + + u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir") + + if u.Email != "" { + t.Errorf("Email: got %q, want empty string", u.Email) + } +} + +func TestExtractUserFromPatient_OfficialNamePreferred(t *testing.T) { + // When both "usual" and "official" names are present, "official" must win. + p := &fhir.Patient{ + ID: "patient-004", + Name: []fhir.HumanName{ + {Use: "usual", Family: "Nickname", Given: []string{"Nick"}}, + {Use: "official", Family: "Registered", Given: []string{"Nicholas", "James"}}, + }, + } + + u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir") + + assertEqual(t, "LastName", "Registered", u.LastName) + assertEqual(t, "FirstName", "Nicholas", u.FirstName) +} + +func TestExtractUserFromPatient_EHRURLTrailingSlashNormalised(t *testing.T) { + p := &fhir.Patient{ID: "patient-005"} + u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir/") + + if u.EHRURL == "https://ehr.example.com/fhir/" { + t.Error("trailing slash should be stripped from EHRURL") + } + assertEqual(t, "EHRURL", "https://ehr.example.com/fhir", u.EHRURL) +} + +// --------------------------------------------------------------------------- +// ExtractUserFromPractitioner tests +// --------------------------------------------------------------------------- + +func TestExtractUserFromPractitioner_FullRecord(t *testing.T) { + p := &fhir.Practitioner{ + ResourceTypeField: "Practitioner", + ID: "pract-001", + Gender: "female", + BirthDate: "1975-09-15", + Name: []fhir.HumanName{{Use: "official", Family: "Chen", Given: []string{"Emily"}}}, + Telecom: []fhir.ContactPoint{ + {System: "email", Value: "dr.chen@hospital.org"}, + }, + } + + u := fhir.ExtractUserFromPractitioner(p, "https://ehr.example.com/fhir") + + assertEqual(t, "Role", string(models.RolePractitioner), string(u.Role)) + assertEqual(t, "FHIRResourceType", "Practitioner", u.FHIRResourceType) + assertEqual(t, "FHIRID", "pract-001", u.FHIRID) + assertEqual(t, "FirstName", "Emily", u.FirstName) + assertEqual(t, "LastName", "Chen", u.LastName) + assertEqual(t, "Email", "dr.chen@hospital.org", u.Email) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func assertEqual(t *testing.T, field, want, got string) { + t.Helper() + if got != want { + t.Errorf("%s: got %q, want %q", field, got, want) + } +} diff --git a/app/handlers/auth.go b/app/handlers/auth.go new file mode 100644 index 0000000..2edf654 --- /dev/null +++ b/app/handlers/auth.go @@ -0,0 +1,257 @@ +// auth.go handles the OAuth2 authorization callback, token exchange, +// FHIR resource fetching, user upsert, and session creation. +// +// Flow (continued from launch.go): +// 1. EHR calls GET /auth-redirect?code=&state= +// 2. Recover launch context from the state store (validates state, prevents CSRF). +// 3. Fetch the EHR's token endpoint from SMART discovery. +// 4. Exchange the authorization code for an access token. +// 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) +// 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. +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// HandleAuthRedirect processes the SMART on FHIR authorization callback. +// GET /auth-redirect?code=&state= +func (h *Handler) HandleAuthRedirect(w http.ResponseWriter, r *http.Request) { + log.Printf("handlers: auth redirect query=%v", r.URL.Query()) + + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + if code == "" || state == "" { + h.renderError(w, http.StatusBadRequest, "Missing code or state parameter.") + return + } + + // Recover and validate the launch context from the server-side state store. + // This is the CSRF protection — the state token is single-use and time-limited. + lc, ok := globalStateStore.get(state) + if !ok { + h.renderError(w, http.StatusBadRequest, "Invalid or expired state parameter.") + return + } + + // Confirm the EHR is still registered (config could theoretically change). + ehrConfig := h.cfg.EHRByURL(lc.ISS) + if ehrConfig == nil { + h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.") + return + } + + // Fetch the SMART discovery document to get the token endpoint. + smartCfg, err := fhir.GetSmartConfiguration(lc.ISS) + if err != nil { + log.Printf("handlers: SMART discovery failed for iss=%q: %v", lc.ISS, err) + h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration.") + return + } + + if smartCfg.TokenEndpoint == "" { + h.renderError(w, http.StatusBadGateway, "SMART configuration missing token_endpoint.") + return + } + + // Exchange the authorization code for an access token. + tokenResp, err := exchangeCode( + smartCfg.TokenEndpoint, + ehrConfig.ClientID, + ehrConfig.ClientSecret, + h.cfg.SMART.RedirectURL, + code, + ) + if err != nil { + log.Printf("handlers: token exchange failed: %v", err) + h.renderError(w, http.StatusBadGateway, "Failed to exchange authorization code for token.") + return + } + + if tokenResp.AccessToken == "" { + h.renderError(w, http.StatusBadGateway, "Token response missing access_token.") + return + } + if tokenResp.Patient == "" { + h.renderError(w, http.StatusBadGateway, "Token response missing patient context.") + return + } + + log.Printf("handlers: token response patient=%q practitioner=%q user=%q", + tokenResp.Patient, tokenResp.Practitioner, tokenResp.User) + + // Build a typed FHIR client for subsequent resource calls. + fhirClient := fhir.NewClient(lc.ISS, tokenResp.AccessToken) + + // --- Fetch and upsert the Patient --- + patient, err := fhirClient.GetPatient(tokenResp.Patient) + if err != nil { + log.Printf("handlers: fetch Patient/%s failed: %v", tokenResp.Patient, err) + h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details.") + return + } + + patientUser := fhir.ExtractUserFromPatient(patient, lc.ISS) + patientInternalID, err := h.store.UpsertUser(patientUser) + if err != nil { + log.Printf("handlers: upsert patient failed: %v", err) + h.renderError(w, http.StatusInternalServerError, "Failed to persist patient record.") + return + } + log.Printf("handlers: upserted patient fhir_id=%s internal_id=%s", patient.ID, patientInternalID) + + // --- Resolve the practitioner FHIR ID --- + // The SMART spec allows the practitioner to be communicated in several ways: + // 1. tokenResp.Practitioner — a bare FHIR resource ID + // 2. tokenResp.User — a relative reference (e.g. "Practitioner/123") + // 3. id_token.fhirUser — a relative or absolute URL (OIDC standard) + // + // We check them in order of specificity. + practitionerFHIRID := tokenResp.Practitioner + if practitionerFHIRID == "" { + // Try the legacy "user" field. + practitionerFHIRID = parsePractitionerFromUserField(tokenResp.User) + } + if practitionerFHIRID == "" && tokenResp.IDToken != "" { + // Try the OIDC fhirUser claim. + fhirUserClaim := fhir.ParseFHIRUserFromIDToken(tokenResp.IDToken) + log.Printf("handlers: inspecting id_token fhirUser=%q", fhirUserClaim) + practitionerFHIRID = parsePractitionerFromUserField(fhirUserClaim) + } + + // --- Resolve the Practitioner or fallback to Patient for the session --- + var sessionUserID string + var practitionerUser *models.User + + if practitionerFHIRID != "" { + practitioner, err := fhirClient.GetPractitioner(practitionerFHIRID) + if err != nil { + // Non-fatal: log and continue. + log.Printf("handlers: fetch Practitioner/%s failed (non-fatal): %v", practitionerFHIRID, err) + } else { + practitionerUser = fhir.ExtractUserFromPractitioner(practitioner, lc.ISS) + practInternalID, err := h.store.UpsertUser(practitionerUser) + if err != nil { + log.Printf("handlers: upsert practitioner failed: %v", err) + h.renderError(w, http.StatusInternalServerError, "Failed to persist practitioner record.") + return + } + log.Printf("handlers: upserted practitioner fhir_id=%s internal_id=%s", practitioner.ID, practInternalID) + sessionUserID = practInternalID + } + } + + // If no practitioner was resolved, fallback to the patient's identity to + // establish a session (common in patient-facing or testing flows). + if sessionUserID == "" { + log.Printf("handlers: no practitioner identity found — falling back to patient identity for session") + sessionUserID = patientInternalID + practitionerUser = patientUser // For the UI to show who is "logged in" + } + + // --- Create session --- + if sessionUserID != "" { + sess, err := h.store.CreateSession(sessionUserID, tokenResp.Patient, tokenResp.AccessToken, tokenResp.IDToken, tokenResp.Scope, lc.ISS, 8*time.Hour) + if err != nil { + log.Printf("handlers: create session failed: %v", err) + h.renderError(w, http.StatusInternalServerError, "Failed to create session.") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: sess.ID, + Path: "/", + MaxAge: SessionTTL, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + log.Printf("handlers: session created id=%s for practitioner user_id=%s", sess.ID, sessionUserID) + } + + // --- Redirect to the stable dashboard --- + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +// parsePractitionerFromUserField extracts a bare Practitioner FHIR ID from a +// SMART "user" claim or fhirUser OIDC claim. +// The input may be: +// - 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. + if strings.HasPrefix(user, "http") { + u, err := url.Parse(user) + if err == nil { + user = u.Path + } + } + + // Remove leading slashes if any. + user = strings.TrimLeft(user, "/") + + const prefix = "Practitioner/" + // We check for the prefix anywhere in the path to handle potential sub-paths. + if idx := strings.Index(user, prefix); idx != -1 { + return strings.TrimPrefix(user[idx:], prefix) + } + + return "" +} + +// exchangeCode performs the OAuth2 authorization_code token exchange. +// Returns an error on any network failure or non-200 HTTP status. +func exchangeCode(tokenEndpoint, clientID, clientSecret, redirectURI, code string) (*fhir.TokenResponse, error) { + formData := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + + req, err := http.NewRequest(http.MethodPost, tokenEndpoint, strings.NewReader(formData.Encode())) + if err != nil { + return nil, fmt.Errorf("build token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read token response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp fhir.TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("decode token response: %w", err) + } + return &tokenResp, nil +} diff --git a/app/handlers/dashboard.go b/app/handlers/dashboard.go new file mode 100644 index 0000000..e59a8cb --- /dev/null +++ b/app/handlers/dashboard.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" + "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// HandleDashboard renders the stable patient dashboard. +// All 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 +func (h *Handler) HandleDashboard(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 + } + + ehrURL := sess.EHRURL + patientID := sess.PatientFHIRID + + // 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) + patient, err := fhirClient.GetPatient(patientID) + if err != nil { + log.Printf("handlers: dashboard fetch Patient/%s failed: %v", patientID, err) + h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details from the FHIR server.") + return + } + patientUser := fhir.ExtractUserFromPatient(patient, ehrURL) + + // Read clinical data from the local database. + observations, err := h.store.ListObservations(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err) + // Non-fatal; render with empty slice. + } + + conditions, err := h.store.ListConditions(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListConditions Patient/%s: %v", patientID, err) + } + + docRefs, err := h.store.ListDocumentReferences(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err) + } + + latestSync, err := h.store.LatestSync(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err) + } + + h.render(w, "dashboard.html", dashboardData{ + Patient: patientUser, + Practitioner: practitionerUser, + RawPatient: patient, + Observations: observations, + Conditions: conditions, + DocumentReferences: docRefs, + LatestSync: latestSync, + Session: sess, + }) +} + +// dashboardData is the view model passed to the dashboard template. +type dashboardData struct { + Patient *models.User + Practitioner *models.User + RawPatient *fhir.Patient + Observations []models.Observation + Conditions []models.Condition + DocumentReferences []models.DocumentReference + LatestSync *models.PatientSync + Session *models.Session +} + +// handleUnauthorized redirects to root for dashboard requests. +func (h *Handler) handleUnauthorized(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/app/handlers/handler.go b/app/handlers/handler.go new file mode 100644 index 0000000..f3c4d9a --- /dev/null +++ b/app/handlers/handler.go @@ -0,0 +1,156 @@ +// Package handlers contains all HTTP handler implementations for the platform. +// +// Handler design: +// - All handlers are methods on *Handler, which aggregates all dependencies +// (store, config, templateFS). This avoids package-level globals and makes +// dependencies explicit and testable. +// - Handlers do not perform FHIR API calls directly; they delegate to the +// fhir package. This keeps HTTP concerns separate from FHIR protocol logic. +// - Templates are parsed per-render as a (base.html + page.html) pair. +// This is the correct Go html/template pattern for layout inheritance: +// a single global template.Set with multiple files all defining "content" +// blocks will have the last-parsed definition win, causing incorrect renders. +// Per-render parsing is cheap (microseconds) and completely correct. +// - Each handler file handles one logical concern: +// handler.go — shared Handler type and constructor +// launch.go — SMART EHR launch initiation +// auth.go — OAuth2 callback, token exchange, user upsert, session creation +// logout.go — session invalidation +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "html/template" + "io/fs" + "log" + "net/http" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/config" + "github.com/AmanTahiliani/FHIR-Sandbox/app/db" +) + +const ( + // SessionCookieName is the name of the HttpOnly session cookie. + SessionCookieName = "session_id" + + // SessionTTL is how long a session remains valid after SMART launch. + SessionTTL = 8 * 60 * 60 // 8 hours in seconds +) + +// Handler is the central handler struct. All HTTP handlers are methods on it. +// It holds all dependencies so they can be injected in tests. +type Handler struct { + store *db.Store + cfg *config.AppConfig + templateFS fs.FS + funcMap template.FuncMap +} + +// New creates a Handler with all dependencies wired in. +// templateFS must be an fs.FS rooted so that "base.html", "dashboard.html", +// etc. are directly accessible (i.e. pass an fs.Sub of the embed.FS). +func New(store *db.Store, cfg *config.AppConfig, templateFS fs.FS, funcMap template.FuncMap) *Handler { + return &Handler{ + store: store, + cfg: cfg, + templateFS: templateFS, + funcMap: funcMap, + } +} + +// render parses base.html + the named page file and executes the combined +// template set, using the page filename as the entry point. +// +// Go's html/template block/define system works correctly when each page +// is parsed together with base.html in a fresh template.Template — the +// page's {{define "content"}} overrides the {{block "content"}} in base.html +// without conflicting with other pages' definitions. +func (h *Handler) render(w http.ResponseWriter, page string, data interface{}) { + tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", page) + if err != nil { + log.Printf("handlers: parse template %q: %v", page, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil { + log.Printf("handlers: execute template %q: %v", page, err) + } +} + +// renderError writes a clean HTML error page. +func (h *Handler) renderError(w http.ResponseWriter, code int, message string) { + data := struct { + Code int + Message string + }{code, message} + tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", "error.html") + if err != nil { + log.Printf("handlers: parse error template: %v", err) + http.Error(w, message, code) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(code) + if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil { + log.Printf("handlers: execute error template: %v", err) + http.Error(w, message, code) + } +} + +// generateState creates a cryptographically secure random state token. +// This replaces the naive iss+launchID concatenation in the original code. +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// templateFuncs returns the custom template function map. +// Defined here so it is available to both main.go (for wiring) and +// handler tests. +func TemplateFuncs() template.FuncMap { + return template.FuncMap{ + "formatDate": func(s string) string { + if s == "" { + return "—" + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return s + } + return t.Format("January 2, 2006") + }, + "formatDateTime": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.UTC().Format("Jan 2, 2006 15:04 UTC") + }, + "derefFloat64": func(p *float64) float64 { + if p == nil { + return 0 + } + return *p + }, + "titleCase": func(s string) string { + if s == "" { + return "—" + } + if len(s) == 1 { + return string(s[0] - 32) + } + return string(s[0]-32) + s[1:] + }, + "orDash": func(s string) string { + if s == "" { + return "—" + } + return s + }, + } +} diff --git a/app/handlers/launch.go b/app/handlers/launch.go new file mode 100644 index 0000000..4737042 --- /dev/null +++ b/app/handlers/launch.go @@ -0,0 +1,177 @@ +// launch.go handles the SMART on FHIR EHR launch initiation sequence. +// +// Flow: +// 1. EHR calls GET /launch?iss=&launch= +// 2. This handler validates iss against the registered EHR list. +// 3. Fetches the SMART discovery document (.well-known/smart-configuration). +// 4. Generates a cryptographically secure state token. +// 5. Stores launch context (iss + launch token) in a short-lived in-memory +// map keyed by state, so the callback can recover context without +// encoding sensitive values in the state URL parameter. +// 6. Redirects the browser to the EHR's authorization_endpoint. +package handlers + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" +) + +// launchState holds the OAuth2 launch context that must survive the +// browser redirect round-trip. It is keyed by a random state token. +type launchState struct { + ISS string + LaunchID string + StoredAt time.Time + UsedAt time.Time // Added to track when the state was first consumed. +} + +// stateStore is a short-lived, in-memory map from state token → launchState. +// Entries are expired after 10 minutes to limit memory growth from abandoned +// launch flows. In a multi-instance deployment this should be replaced with +// a shared store (e.g., Redis). +type stateStore struct { + mu sync.Mutex + entries map[string]launchState +} + +var globalStateStore = &stateStore{ + entries: make(map[string]launchState), +} + +// set stores a launch state entry. +func (ss *stateStore) set(token string, ls launchState) { + ss.mu.Lock() + defer ss.mu.Unlock() + // Opportunistically evict stale entries on every write. + ss.evict() + ss.entries[token] = ls +} + +// get retrieves a state entry. Returns false if not found or expired. +// Implements a 10-second grace period for duplicate requests (common in some +// browsers/environments) after the first consumption. +func (ss *stateStore) get(token string) (launchState, bool) { + ss.mu.Lock() + defer ss.mu.Unlock() + ls, ok := ss.entries[token] + if !ok { + return launchState{}, false + } + + // If already used more than 10 seconds ago, consider it fully consumed. + if !ls.UsedAt.IsZero() && time.Since(ls.UsedAt) > 10*time.Second { + delete(ss.entries, token) + return launchState{}, false + } + + if time.Since(ls.StoredAt) > 10*time.Minute { + delete(ss.entries, token) + return launchState{}, false + } + + // Mark as used but don't delete yet to allow for race conditions/double-requests. + if ls.UsedAt.IsZero() { + ls.UsedAt = time.Now() + ss.entries[token] = ls + } + + return ls, true +} + +// evict removes entries older than 10 minutes. Must be called with ss.mu held. +func (ss *stateStore) evict() { + cutoff := time.Now().Add(-10 * time.Minute) + for k, v := range ss.entries { + if v.StoredAt.Before(cutoff) { + delete(ss.entries, k) + } + } +} + +// HandleRoot serves the application home page. +func (h *Handler) HandleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + h.renderError(w, http.StatusNotFound, "Page not found.") + return + } + h.render(w, "index.html", nil) +} + +// HandleLaunch processes the SMART EHR launch initiation. +// GET /launch?iss=&launch= +func (h *Handler) HandleLaunch(w http.ResponseWriter, r *http.Request) { + launchID := r.URL.Query().Get("launch") + iss := r.URL.Query().Get("iss") + + log.Printf("handlers: launch request iss=%q launch=%q", iss, launchID) + + if launchID == "" || iss == "" { + h.renderError(w, http.StatusBadRequest, "Missing required parameters: iss and launch.") + return + } + + // Validate iss is a well-formed URI before doing anything with it. + if _, err := url.ParseRequestURI(iss); err != nil { + h.renderError(w, http.StatusBadRequest, "Invalid FHIR server URL (iss).") + return + } + + // Confirm this EHR is registered. + ehrConfig := h.cfg.EHRByURL(iss) + if ehrConfig == nil { + log.Printf("handlers: unregistered EHR iss=%q", iss) + h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.") + return + } + + // Fetch SMART discovery document. + smartCfg, err := fhir.GetSmartConfiguration(iss) + if err != nil { + log.Printf("handlers: SMART discovery failed for iss=%q: %v", iss, err) + h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration from FHIR server.") + return + } + + if smartCfg.AuthorizationEndpoint == "" { + h.renderError(w, http.StatusBadGateway, "SMART configuration missing authorization_endpoint.") + return + } + + // Generate a secure random state token. + state, err := generateState() + if err != nil { + log.Printf("handlers: state generation failed: %v", err) + h.renderError(w, http.StatusInternalServerError, "Internal error.") + return + } + + // Store the launch context server-side, keyed by state. + globalStateStore.set(state, launchState{ + ISS: strings.TrimRight(iss, "/"), + LaunchID: launchID, + StoredAt: time.Now(), + }) + + // Build the authorization URL. + scopes := strings.Join(h.cfg.SMART.Scopes, " ") + authURL := fmt.Sprintf( + "%s?response_type=code&client_id=%s&redirect_uri=%s&launch=%s&scope=%s&state=%s&aud=%s", + smartCfg.AuthorizationEndpoint, + url.QueryEscape(ehrConfig.ClientID), + url.QueryEscape(h.cfg.SMART.RedirectURL), + url.QueryEscape(launchID), + url.QueryEscape(scopes), + url.QueryEscape(state), + url.QueryEscape(iss), + ) + + log.Printf("handlers: redirecting to authorization endpoint for EHR %q", ehrConfig.Name) + http.Redirect(w, r, authURL, http.StatusFound) +} diff --git a/app/handlers/logout.go b/app/handlers/logout.go new file mode 100644 index 0000000..4ad3d5f --- /dev/null +++ b/app/handlers/logout.go @@ -0,0 +1,32 @@ +// logout.go handles session invalidation and user logout. +package handlers + +import ( + "log" + "net/http" +) + +// HandleLogout invalidates the current session and redirects to the home page. +// POST /logout +func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookieName) + if err == nil && cookie.Value != "" { + if delErr := h.store.DeleteSession(cookie.Value); delErr != nil { + log.Printf("handlers: logout delete session %s: %v", cookie.Value, delErr) + } else { + log.Printf("handlers: logged out session %s", cookie.Value) + } + } + + // Clear the cookie in the browser regardless of DB outcome. + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/app/handlers/sync.go b/app/handlers/sync.go new file mode 100644 index 0000000..cb08e14 --- /dev/null +++ b/app/handlers/sync.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "log" + "net/http" + + "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. +// +// POST /dashboard/sync +func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + sess := middleware.SessionFromContext(r.Context()) + if sess == nil { + h.handleUnauthorized(w, r) + return + } + + ehrURL := sess.EHRURL + patientID := sess.PatientFHIRID + client := fhir.NewClient(ehrURL, sess.AccessToken) + + // ----------------------------------------------------------------- + // Fetch Observations + // ----------------------------------------------------------------- + rawObs, err := client.GetObservations(patientID) + if err != nil { + log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err) + // Non-fatal; continue with whatever we got. + } + + obsCount := 0 + for i := range rawObs { + m := fhir.ExtractObservation(&rawObs[i], patientID, ehrURL) + if _, err := h.store.UpsertObservation(m); err != nil { + log.Printf("handlers: sync UpsertObservation fhir_id=%s: %v", m.FHIRID, err) + continue + } + obsCount++ + } + + // ----------------------------------------------------------------- + // Fetch Conditions + // ----------------------------------------------------------------- + rawConds, err := client.GetConditions(patientID) + if err != nil { + log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err) + } + + condCount := 0 + for i := range rawConds { + m := fhir.ExtractCondition(&rawConds[i], patientID, ehrURL) + if _, err := h.store.UpsertCondition(m); err != nil { + log.Printf("handlers: sync UpsertCondition fhir_id=%s: %v", m.FHIRID, err) + continue + } + condCount++ + } + + // ----------------------------------------------------------------- + // Fetch DocumentReferences + // ----------------------------------------------------------------- + rawDocs, err := client.GetDocumentReferences(patientID) + if err != nil { + log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err) + } + + docCount := 0 + for i := range rawDocs { + m := fhir.ExtractDocumentReference(&rawDocs[i], patientID, ehrURL) + if _, err := h.store.UpsertDocumentReference(m); err != nil { + log.Printf("handlers: sync UpsertDocumentReference fhir_id=%s: %v", m.FHIRID, err) + continue + } + docCount++ + } + + // ----------------------------------------------------------------- + // Record the sync event + // ----------------------------------------------------------------- + if _, err := h.store.RecordSync(patientID, ehrURL, obsCount, condCount, docCount); err != nil { + 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) + + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} diff --git a/app/main.go b/app/main.go index 7384bf7..6fe3a1c 100644 --- a/app/main.go +++ b/app/main.go @@ -1,370 +1,121 @@ +// main.go is the application entry point. Its only job is to: +// 1. Load configuration. +// 2. Initialize the database store. +// 3. Expose the embedded template FS to the handlers package. +// 4. Wire all dependencies into handlers and middleware. +// 5. Register routes and start the HTTP server. +// +// No business logic lives here. All behaviour is delegated to the +// handlers, db, fhir, config, and middleware packages. package main import ( - "bytes" - "encoding/json" + "embed" "fmt" - "io" + "io/fs" "log" "net/http" - "net/url" - "strings" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/config" + "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/handlers" + "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" ) -type Application struct { - config *ApplicationConfig -} - -type ApplicationConfig struct { - smart *SMARTAppConfig - eHRs []EHRClientsConfig // List of EHR clients configuration -} - -type SMARTAppConfig struct { - oAuth1 bool // Enable OAuth1 for certain EHRs - oAuth2 bool // Enable OAuth2 for others - redirectPath string // Where to redirect after auth - clientID string // OAuth client ID (if applicable) - clientSecret string // OAuth client secret - scopes []string -} - -type EHRClientsConfig struct { - name string // Name of the EHR, e.g., "EHR_A" - url string // FHIR endpoint URL - authType string // auth-1 or auth-2 for OAuth type - clientID string // OAuth client ID (for Azure AD) - clientSecret string // OAuth secret (for Azure AD) - patientAPI bool // Whether to enable patient API endpoints -} - -type LaunchContext struct { - LaunchID string `json:"launch"` - Patient string `json:"patient"` -} +//go:embed templates/*.html +var embeddedTemplates embed.FS func main() { - log.Printf("Starting EHR integration application...") - - appConfig := &ApplicationConfig{ - smart: &SMARTAppConfig{ - redirectPath: "http://localhost:8080/auth-redirect", - clientID: "my-smart-client-id", - clientSecret: "my-smart-client-secret", - scopes: []string{"launch", "patient/*.read"}, - oAuth1: true, - oAuth2: false, + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + cfg := &config.AppConfig{ + DBPath: "fhir_sandbox.db", + Server: config.ServerConfig{ + Port: 8080, }, - eHRs: []EHRClientsConfig{ // Sample EHR clients configuration + SMART: config.SMARTConfig{ + RedirectURL: "http://localhost:8080/auth-redirect", + Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"}, + }, + EHRs: []config.EHRConfig{ { - name: "EHR_A", - url: "https://launch.smarthealthit.org/v/r4/fhir", - authType: "auth-2", - clientID: "abcdefghijklmnopqrst", - clientSecret: "ehr_a_client_secret", - patientAPI: true, - }, - { - name: "EHR_B", - url: "https://ehr-b.healthcare-provider.com/fhir/v2", - authType: "auth-2", - clientID: "ehr_b_client_id", // OAuth 2.0 client ID - clientSecret: "ehr_b_client_secret", - patientAPI: true, + Name: "SmartHealthIT Sandbox (R4)", + FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", + ClientID: "abcdefghijklmnopqrst", + ClientSecret: "ehr_a_client_secret", }, + // Add additional EHR configurations here as needed. }, } - app := &Application{ - config: appConfig, + // ------------------------------------------------------------------------- + // Database + // ------------------------------------------------------------------------- + store, err := db.New(cfg.DBPath) + if err != nil { + log.Fatalf("main: database init failed: %v", err) + } + defer store.Close() + log.Printf("main: database ready at %q", cfg.DBPath) + + // Purge expired sessions on startup so the table doesn't accumulate stale rows. + if n, err := store.DeleteExpiredSessions(); err != nil { + log.Printf("main: warning — could not purge expired sessions: %v", err) + } else if n > 0 { + log.Printf("main: purged %d expired session(s)", n) } - log.Printf("Listening on port %d...", 8080) + // ------------------------------------------------------------------------- + // Templates + // Sub the embed.FS to strip the "templates/" prefix so handlers can + // reference files as "base.html", "dashboard.html", etc. + // ------------------------------------------------------------------------- + templateFS, err := fs.Sub(embeddedTemplates, "templates") + if err != nil { + log.Fatalf("main: template FS sub failed: %v", err) + } - // Start HTTP server and handle routes - http.HandleFunc("/", app.handleRoot) - http.HandleFunc("/launch", app.handleLaunch) - http.HandleFunc("/auth-redirect", app.handleAuthRedirect) + // ------------------------------------------------------------------------- + // Handlers & middleware + // ------------------------------------------------------------------------- + h := handlers.New(store, cfg, templateFS, handlers.TemplateFuncs()) + sessionMW := middleware.NewSessionMiddleware(store) - log.Fatal(http.ListenAndServe(":8080", nil)) -} - -// handleRoot is a method of Application that handles the root endpoint. -func (app *Application) handleRoot(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("Welcome to the EHR integration application!")) -} - -func getWellKnownUrl(iss string) (string, error) { - wellKnownUrl := fmt.Sprintf("%s/.well-known/smart-configuration", iss) - resp, err := http.Get(wellKnownUrl) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to fetch well-known URL") - } - - var bodyBytes []byte - bodyBytes, err = io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(bodyBytes), nil - -} - -// handleLaunch processes the SMART on FHIR launch request. -func (app *Application) handleLaunch(w http.ResponseWriter, r *http.Request) { - launchID := r.URL.Query().Get("launch") - iss := r.URL.Query().Get("iss") - // Log the query parameters and body data for debugging - log.Printf("Query parameters: %v", r.URL.Query()) - if launchID == "" || iss == "" { - http.Error(w, "Missing launch or iss parameter", http.StatusBadRequest) - return - } - - // Validate the FHIR server base URL (iss) - _, err := url.ParseRequestURI(iss) - if err != nil { - http.Error(w, "Invalid FHIR server base URL (iss)", http.StatusBadRequest) - return - } - - //Make sure the FHIR server base URL (iss) is registered in the application's configuration - var ehrConfig *EHRClientsConfig - for _, ehr := range app.config.eHRs { - if ehr.url == iss { - ehrConfig = &ehr - break - } - } - - if ehrConfig == nil { - http.Error(w, "Unsupported FHIR server base URL (iss)", http.StatusBadRequest) - return - } - - var clientID string - var clientSecret string - clientID = ehrConfig.clientID - clientSecret = ehrConfig.clientSecret - - if clientID == "" || clientSecret == "" { - http.Error(w, "Missing client ID or client secret", http.StatusBadRequest) - return - } - - wellKnownBody, err := getWellKnownUrl(iss) - if err != nil { - http.Error(w, "Failed to fetch SMART configuration from FHIR server", http.StatusInternalServerError) - return - } - // Get the authorization endpoint from the wellKnownBody and use it to construct the authorization URL - var discoveryDoc map[string]interface{} - if err := json.NewDecoder(strings.NewReader(wellKnownBody)).Decode(&discoveryDoc); err != nil { - http.Error(w, "Invalid SMART configuration response", http.StatusInternalServerError) - return - } - - authEndpoint, ok := discoveryDoc["authorization_endpoint"].(string) - if !ok || authEndpoint == "" { - http.Error(w, "Authorization endpoint not found in SMART configuration", http.StatusInternalServerError) - return - } - - // Generate a state value that can be used to identify the EHR client and can be used to prevent CSRF attacks. - state := iss + "state_hash" + launchID - - // Construct the authorization URL - authURL := fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&launch=%s&scope=openid+profile+launch+patient/*.read&state=%s&aud=%s", - authEndpoint, clientID, url.QueryEscape(app.config.smart.redirectPath), launchID, state, iss) - - http.Redirect(w, r, authURL, http.StatusFound) -} - -// handleAuthRedirect processes the authorization redirect and retrieves patient details. -func (app *Application) handleAuthRedirect(w http.ResponseWriter, r *http.Request) { - // Log the query parameters and body data for debugging - log.Printf("Query parameters: %v", r.URL.Query()) - - bodyData := make(map[string]interface{}) - if err := json.NewDecoder(r.Body).Decode(&bodyData); err != nil { - log.Printf("Failed to decode body data: %v", err) - } else { - log.Printf("Body data: %v", bodyData) - } - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - - if code == "" || state == "" { - http.Error(w, "Missing code or state parameter", http.StatusBadRequest) - return - } - // Extract the EHR client ID from the state parameter - stateParts := strings.Split(state, "state_hash") - if len(stateParts) != 2 { - http.Error(w, "Invalid state parameter", http.StatusBadRequest) - return - } - ehr_iss := stateParts[0] - // Fetch the EHR client configuration based on the EHR client ID - var ehrConfig *EHRClientsConfig - for _, ehr := range app.config.eHRs { - if ehr.url == ehr_iss { - ehrConfig = &ehr - break - } - } - if ehrConfig == nil { - http.Error(w, "Unsupported EHR client", http.StatusBadRequest) - return - } - wellKnownBody, err := getWellKnownUrl(ehr_iss) - if err != nil { - http.Error(w, "Failed to fetch SMART configuration from FHIR server", http.StatusInternalServerError) - return - } - // Construct the token request URL from the well-knownBody - var discoveryDoc map[string]interface{} - if err := json.NewDecoder(strings.NewReader(wellKnownBody)).Decode(&discoveryDoc); err != nil { - http.Error(w, "Invalid SMART configuration response", http.StatusInternalServerError) - return - } - tokenEndpoint, ok := discoveryDoc["token_endpoint"].(string) - if !ok || tokenEndpoint == "" { - http.Error(w, "Token endpoint not found in SMART configuration", http.StatusInternalServerError) - return - } - - // Construct the token request URL from the well-knownBody - // Prepare form data for token request - formData := url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "redirect_uri": {app.config.smart.redirectPath}, - } - - // Create request with Basic authentication - req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(formData.Encode())) - if err != nil { - http.Error(w, "Failed to exchange authorization code for token", http.StatusInternalServerError) - return - } - - // Add required headers - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(ehrConfig.clientID, ehrConfig.clientSecret) - - // Make the token request - resp, err := http.DefaultClient.Do(req) - if err != nil { - log.Printf("Failed to make token request: %v", err) - http.Error(w, "Failed to exchange authorization code for token: ", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - log.Printf("Failed to exchange authorization code for token: %v", resp.Status) - - // Decode and log the response body - var responseBody map[string]interface{} - bodyBytes, _ := io.ReadAll(resp.Body) - if err := json.Unmarshal(bodyBytes, &responseBody); err != nil { - log.Printf("Failed to decode token response body: %v", err) - } else { - log.Printf("Token response: %v", responseBody) - } - // Reset the response body for later use - resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - http.Error(w, "Failed to exchange authorization code for token", http.StatusInternalServerError) - return - } - // Parse the token response - var tokenResponse map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { - http.Error(w, "Invalid token response", http.StatusInternalServerError) - return - } - //log the token response - log.Printf("Token response: %v", tokenResponse) - // Extract the access token and patient ID from the token response - accessToken, ok := tokenResponse["access_token"].(string) - if !ok || accessToken == "" { - http.Error(w, "Access token not found in token response", http.StatusInternalServerError) - return - } - patientID, ok := tokenResponse["patient"].(string) - if !ok || patientID == "" { - http.Error(w, "Patient ID not found in token response", http.StatusInternalServerError) - return - } - // Fetch patient details from the EHR using the access token - patientURL := fmt.Sprintf("%s/Patient/%s", ehrConfig.url, patientID) - - req, err = http.NewRequest("GET", patientURL, nil) - if err != nil { - http.Error(w, "Failed to create patient details request", http.StatusInternalServerError) - return - } - req.Header.Set("Authorization", "Bearer "+accessToken) - resp, err = http.DefaultClient.Do(req) - if err != nil { - http.Error(w, "Failed to fetch patient details", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - http.Error(w, "Failed to fetch patient details", http.StatusInternalServerError) - return - } - // Parse the patient details response - var patientDetails map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&patientDetails); err != nil { - http.Error(w, "Invalid patient details response", http.StatusInternalServerError) - return - } - // Log the patient details for debugging purposes - log.Printf("Patient details: %v", patientDetails) - // Render the patient details in the response - w.Header().Set("Content-Type", "text/html") - // Create HTML table header - html := "" - html += "

Patient Details

" - - // Recursively build table rows from JSON - var buildTableRows func(data map[string]interface{}, indent string) string - buildTableRows = func(data map[string]interface{}, indent string) string { - var rows string - for key, value := range data { - rows += "" - rows += fmt.Sprintf("", indent, key) - - switch v := value.(type) { - case map[string]interface{}: - rows += "" - case []interface{}: - rows += "" - default: - rows += fmt.Sprintf("", v) - } - rows += "" - } - return rows - } - - html += buildTableRows(patientDetails, "") - html += "
%s%s" + buildTableRows(v, indent+"  ") + "" - for _, item := range v { - if m, ok := item.(map[string]interface{}); ok { - rows += buildTableRows(m, indent+"  ") - } else { - rows += fmt.Sprintf("", item) - } - } - rows += "
%v
%v
" - - w.Write([]byte(html)) + // ------------------------------------------------------------------------- + // Routes + // ------------------------------------------------------------------------- + mux := http.NewServeMux() + + // Public routes + mux.HandleFunc("/", h.HandleRoot) + mux.HandleFunc("/launch", h.HandleLaunch) + mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect) + + // 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("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout))) + + // Apply the soft session loader to every request so templates can always + // read the current user from context. + root := sessionMW.LoadSession(mux) + + // ------------------------------------------------------------------------- + // Server + // ------------------------------------------------------------------------- + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: root, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("main: starting FHIR platform on http://localhost%s", addr) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("main: server error: %v", err) + } } diff --git a/app/middleware/session.go b/app/middleware/session.go new file mode 100644 index 0000000..8be5338 --- /dev/null +++ b/app/middleware/session.go @@ -0,0 +1,162 @@ +// Package middleware provides HTTP middleware for the platform. +// +// The session middleware performs two functions: +// 1. RequireSession — a hard gate that returns 401/403 if no valid session +// is present. Use this on protected routes. +// 2. LoadSession — a soft loader that attaches session+user to the context +// if a valid cookie is present but does NOT block unauthenticated requests. +// Use this on public routes that want to show user-aware UI. +package middleware + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +const sessionCookieName = "session_id" + +// SessionMiddleware holds the dependencies needed by the session middleware. +type SessionMiddleware struct { + store *db.Store +} + +// NewSessionMiddleware creates a new SessionMiddleware using the given store. +func NewSessionMiddleware(store *db.Store) *SessionMiddleware { + return &SessionMiddleware{store: store} +} + +// LoadSession is a non-blocking middleware that attempts to resolve a session +// from the request cookie and, if valid, attaches both the Session and User +// to the request context. +// +// Requests without a valid session continue normally — this middleware does +// NOT reject unauthenticated requests. Use RequireSession for protected routes. +func (m *SessionMiddleware) LoadSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + // No cookie — proceed without a session context. + next.ServeHTTP(w, r) + return + } + + sess, user, err := m.resolveSession(cookie.Value) + if err != nil { + // Invalid or expired session — clear the stale cookie and continue. + clearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + + ctx := context.WithValue(r.Context(), models.SessionContextKey{}, sess) + ctx = context.WithValue(ctx, models.UserContextKey{}, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireSession is a hard-gate middleware. It resolves the session cookie and, +// if valid, attaches session+user to the context. If the session is missing or +// invalid, it returns a 401 Unauthorized response (or redirects to "/" for +// browser clients). +func (m *SessionMiddleware) RequireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + m.handleUnauthorized(w, r) + return + } + + sess, user, err := m.resolveSession(cookie.Value) + if err != nil { + clearSessionCookie(w) + m.handleUnauthorized(w, r) + return + } + + ctx := context.WithValue(r.Context(), models.SessionContextKey{}, sess) + ctx = context.WithValue(ctx, models.UserContextKey{}, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// resolveSession looks up the session by token, validates its expiry, +// and fetches the associated user record. Returns both or an error. +func (m *SessionMiddleware) resolveSession(token string) (*models.Session, *models.User, error) { + sess, err := m.store.GetSession(token) + if err == sql.ErrNoRows { + return nil, nil, err + } + if err != nil { + log.Printf("middleware: session lookup error: %v", err) + return nil, nil, err + } + + // Ensure we compare in UTC and handle potential timezone interpretation issues + // from the database driver by forcing both to UTC. + now := time.Now().UTC() + expiresAt := sess.ExpiresAt.UTC() + + if now.After(expiresAt) { + log.Printf("middleware: session %s expired (now=%v, expires=%v)", sess.ID, now, expiresAt) + // Session has expired — clean it up asynchronously. + go func() { + if delErr := m.store.DeleteSession(sess.ID); delErr != nil { + log.Printf("middleware: failed to delete expired session %s: %v", sess.ID, delErr) + } + }() + return nil, nil, fmt.Errorf("session expired") + } + + user, err := m.store.GetUserByID(sess.UserID) + if err != nil { + log.Printf("middleware: user lookup for session %s failed: %v", sess.ID, err) + return nil, nil, err + } + + return sess, user, nil +} + +// handleUnauthorized returns a 401 for API/JSON requests and a redirect to +// the root for browser requests. +func (m *SessionMiddleware) handleUnauthorized(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") == "application/json" || + r.Header.Get("Content-Type") == "application/json" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// clearSessionCookie sends a Set-Cookie header that immediately expires +// the session cookie in the browser. +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// SessionFromContext retrieves the Session from a request context. +// Returns nil if no session has been loaded. +func SessionFromContext(ctx context.Context) *models.Session { + sess, _ := ctx.Value(models.SessionContextKey{}).(*models.Session) + return sess +} + +// UserFromContext retrieves the User from a request context. +// Returns nil if no user has been loaded. +func UserFromContext(ctx context.Context) *models.User { + user, _ := ctx.Value(models.UserContextKey{}).(*models.User) + return user +} diff --git a/app/models/models.go b/app/models/models.go new file mode 100644 index 0000000..45e525b --- /dev/null +++ b/app/models/models.go @@ -0,0 +1,172 @@ +// Package models defines the core domain types used throughout the application. +// These structs map directly to database tables and serve as the canonical +// representation of platform entities. As the platform grows, new resource +// types (e.g., Observation, Condition, MedicationRequest) should be added here. +package models + +import "time" + +// Role represents the access tier of a platform user. +// New roles (e.g., RoleAdmin, RoleCaregiver) can be appended without +// breaking existing comparisons since the underlying type is a string. +type Role string + +const ( + // RolePatient identifies a patient-context user created on SMART launch. + RolePatient Role = "patient" + + // RolePractitioner identifies an HCP who initiated a SMART launch. + RolePractitioner Role = "practitioner" +) + +// User is the canonical platform user record, independent of any specific +// FHIR resource type. It maps to the `users` table. +// +// Design note: FHIRResourceType allows the same struct to represent both +// Patient and Practitioner FHIR resources without a separate table per type. +// Adding support for RelatedPerson or CareTeam members only requires updating +// the fhir package's extraction logic and inserting with the correct type. +type User struct { + // ID is the internal UUID primary key. Never exposed in URLs. + ID string `json:"id" db:"id"` + + // FHIRResourceType is the FHIR resource type string, e.g. "Patient" or "Practitioner". + FHIRResourceType string `json:"fhir_resource_type" db:"fhir_resource_type"` + + // FHIRID is the resource id from the originating EHR FHIR server. + // Combined with EHRURL this forms a globally unique identity. + FHIRID string `json:"fhir_id" db:"fhir_id"` + + // EHRURL is the base FHIR server URL the user was sourced from. + // Storing this prevents ID collisions across EHR tenants. + EHRURL string `json:"ehr_url" db:"ehr_url"` + + // Role defines the user's access tier within this platform. + Role Role `json:"role" db:"role"` + + // Demographics + FirstName string `json:"first_name" db:"first_name"` + MiddleName string `json:"middle_name" db:"middle_name"` + LastName string `json:"last_name" db:"last_name"` + DOB string `json:"dob" db:"dob"` // ISO 8601 date, e.g. "1990-04-22" + Gender string `json:"gender" db:"gender"` // FHIR value set: male|female|other|unknown + + // Email is optional — not all FHIR resources include contact telecom data. + Email string `json:"email" db:"email"` + + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Session represents an active authenticated session for a platform user. +// Sessions are created on SMART launch completion and destroyed on logout. +// Only HCP (RolePractitioner) users create sessions; patients are stored +// passively and do not log in directly via this flow. +type Session struct { + // ID is the session token — a UUID stored as an HttpOnly cookie. + ID string `json:"id" db:"id"` + + // UserID references the authenticated User.ID (internal UUID). + UserID string `json:"user_id" db:"user_id"` + + // AccessToken holds the FHIR Bearer token for the duration of the session, + // enabling subsequent FHIR API calls on behalf of the logged-in HCP. + AccessToken string `json:"access_token" db:"access_token"` + + // IDToken holds the raw OIDC ID token if provided by the EHR. + IDToken string `json:"id_token" db:"id_token"` + + // Scope holds the scopes granted by the EHR during this session. + Scope string `json:"scope" db:"scope"` + + // EHRURL is the FHIR server base URL associated with this session. + // Stored so any handler can construct FHIR API requests without + // re-deriving the EHR context from state parameters. + EHRURL string `json:"ehr_url" db:"ehr_url"` + + // PatientFHIRID is the FHIR ID of the patient in context for this session. + // Stored so the dashboard can fetch patient-specific resources. + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + + CreatedAt time.Time `json:"created_at" db:"created_at"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` +} + +// SessionContextKey is the type used to store the resolved Session in a +// request context. Using a dedicated unexported type avoids key collisions. +type SessionContextKey struct{} + +// UserContextKey is the type used to store the resolved User in a +// request context. +type UserContextKey struct{} + +// --------------------------------------------------------------------------- +// Clinical resource models +// --------------------------------------------------------------------------- + +// 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"` +} + +// Condition is the persisted representation of a FHIR R4 Condition. +type Condition 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"` + 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"` + OnsetDate string `json:"onset_date" db:"onset_date"` + RecordedDate string `json:"recorded_date" db:"recorded_date"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// DocumentReference is the persisted representation of a FHIR R4 DocumentReference. +type DocumentReference 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"` + DocStatus string `json:"doc_status" db:"doc_status"` + TypeText string `json:"type_text" db:"type_text"` + TypeSystem string `json:"type_system" db:"type_system"` + TypeCode string `json:"type_code" db:"type_code"` + Category string `json:"category" db:"category"` + Date string `json:"date" db:"date"` + Description string `json:"description" db:"description"` + ContentType string `json:"content_type" db:"content_type"` + ContentURL string `json:"content_url" db:"content_url"` + ContentData string `json:"content_data" db:"content_data"` + 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"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` + ObsCount int `json:"obs_count" db:"obs_count"` + CondCount int `json:"cond_count" db:"cond_count"` + DocCount int `json:"doc_count" db:"doc_count"` +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..2213e4e --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,238 @@ + + + + + + FHIR Health Platform + + + +
+
FHIR Health Platform
+ +
+ +
+ {{block "content" .}}{{end}} +
+ +
+ FHIR Health Platform — SMART on FHIR R4 +
+ + {{block "scripts" .}}{{end}} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..d30b667 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,335 @@ +{{template "base.html" .}} + +{{define "nav"}} + Home +
+ +
+{{end}} + +{{define "content"}} + +{{/* ---- Practitioner block ---- */}} +{{if .Practitioner}} +
+
Logged-in Clinician
+
+
+ {{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}} +
+
+

+ {{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}} + {{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}} + {{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}} +

+ {{if eq .Practitioner.Role "practitioner"}} + Health Care Practitioner + {{else}} + Patient (Self-Service) + {{end}} +
+
+
+
+ +

{{formatDate .Practitioner.DOB}}

+
+
+ +

{{titleCase .Practitioner.Gender}}

+
+
+ +

{{orDash .Practitioner.Email}}

+
+
+ +

{{.Practitioner.FHIRID}}

+
+
+
+{{else}} +
+

No practitioner context was returned by this EHR. Session not established.

+
+{{end}} + +{{/* ---- Patient block ---- */}} +{{if .Patient}} +
+
Active Patient
+
+
+ {{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}} +
+
+

+ {{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}} + {{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}} + {{if .Patient.LastName}}{{.Patient.LastName}}{{end}} +

+ Patient +
+
+
+
+
+ +

{{formatDate .Patient.DOB}}

+
+
+ +

{{titleCase .Patient.Gender}}

+
+
+ +

{{orDash .Patient.Email}}

+
+
+ +

{{.Patient.FHIRID}}

+
+
+ +

{{.Patient.EHRURL}}

+
+
+ +

{{.Patient.ID}}

+
+
+
+ +{{/* ---- Clinical Data block ---- */}} +
+
+
Clinical Data
+
+ {{if .LatestSync}} + + Last synced: {{formatDateTime .LatestSync.SyncedAt}} +  ·  + {{.LatestSync.ObsCount}} obs · {{.LatestSync.CondCount}} cond · {{.LatestSync.DocCount}} docs + + {{else}} + Never synced + {{end}} +
+ +
+
+
+ +
+ + + + +
+ +
+ {{if .Observations}} + + + + + + + + + + + {{range .Observations}} + + + + + + + {{end}} + +
DateCodeValueStatus
{{orDash .EffectiveDate}}{{orDash .CodeText}} + {{if .ValueQuantity}} + {{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}} + {{else if .ValueString}} + {{.ValueString}} + {{else}} + — + {{end}} + {{.Status}}
+ {{else}} +

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

+ {{end}} +
+ + + + + + +
+ +{{/* ---- Raw FHIR resource accordion ---- */}} +{{if .RawPatient}} +
+ + View Raw FHIR Patient Resource + +
+ + + + + + + + + + + + + + + + + + + + + + + + + {{range .RawPatient.Name}} + + + + + {{end}} + {{range .RawPatient.Telecom}} + + + + + {{end}} + {{range .RawPatient.Address}} + + + + + {{end}} + +
FieldValue
resourceType{{.RawPatient.ResourceTypeField}}
id{{.RawPatient.ID}}
gender{{.RawPatient.Gender}}
birthDate{{.RawPatient.BirthDate}}
name ({{.Use}}) + {{range .Given}}{{.}} {{end}}{{.Family}} +
telecom ({{.System}}){{.Value}}
address + {{range .Line}}{{.}}, {{end}}{{.City}}{{if .State}}, {{.State}}{{end}} {{.PostalCode}} +
+
+
+{{end}} + +{{end}}{{/* end if .Patient */}} + +{{end}}{{/* end content */}} + +{{define "scripts"}} + +{{end}} diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..206f7d2 --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,13 @@ +{{template "base.html" .}} + +{{define "nav"}} + Home +{{end}} + +{{define "content"}} +
+

{{.Code}}

+

{{.Message}}

+ Go Home +
+{{end}} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..7f20d12 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,15 @@ +{{template "base.html" .}} + +{{define "nav"}} + Home +{{end}} + +{{define "content"}} +
+

FHIR Health Platform

+

A SMART on FHIR-powered platform for secure, context-aware EHR integration. Launch from your EHR to get started.

+ + Launch with SmartHealthIT Sandbox + +
+{{end}} diff --git a/go.mod b/go.mod index 72ae54a..5e27651 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module github.com/AmanTahiliani/FHIR-Sandbox go 1.24.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5cf3e13 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= From f21d2039cbcbb1634baf3ba070b0868f1434912f Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 17:17:26 -0500 Subject: [PATCH 2/4] 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}} From 2171991dc89542af9785544ef45d3918d16e0884 Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 17:46:34 -0500 Subject: [PATCH 3/4] UI/UX enhancements --- README.md | 73 ++-- app/handlers/handler.go | 5 - app/main.go | 1 + app/static/css/styles.css | 453 ++++++++++++++++++++ app/templates/base.html | 234 +---------- app/templates/dashboard.html | 788 ++++++++++++++++------------------- app/templates/error.html | 15 +- app/templates/index.html | 60 ++- app/templates/patients.html | 135 +++--- 9 files changed, 1028 insertions(+), 736 deletions(-) create mode 100644 app/static/css/styles.css diff --git a/README.md b/README.md index 455055f..df0477c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,34 @@ # FHIR-Sandbox: SMART on FHIR Healthcare Platform -A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data. +A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data, serving as a robust starting point for healthcare applications. ## Features -- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone). +- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone) with OAuth2 code exchange. - **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses. -- **SQLite Persistence:** Persists patient and practitioner data upon successful launch using a pure-Go SQLite driver (no CGO required). -- **Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies. -- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics and practitioner details. -- **Extensible Architecture:** Clean package separation (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`) designed for growth. +- **Comprehensive FHIR Sync:** Automatically synchronizes and persists key clinical data: + - Patient Demographics + - Observations (Vitals, Labs) + - Conditions (Problems) + - DocumentReferences + - MedicationRequests + - AllergyIntolerances +- **SQLite Persistence:** Persists all synced data using a pure-Go SQLite driver (`modernc.org/sqlite`), requiring no CGO or external database server. +- **Secure Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies. +- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics, clinical data, and practitioner details. +- **Extensible Architecture:** Modular design with clean separation of concerns (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`). ## Architecture The project is structured into modular packages under `/app`: -- `/db`: Database schema, migrations, and CRUD operations using `modernc.org/sqlite`. -- `/fhir`: FHIR R4 resource definitions and SMART discovery/client logic. -- `/handlers`: HTTP request handlers and template rendering. -- `/middleware`: Session loading and authentication guards. -- `/models`: Shared data structures. -- `/templates`: HTML templates with layout inheritance. + +- `/config`: Configuration structures and URL normalization. +- `/db`: Database schema, versioned migrations, and CRUD operations using `modernc.org/sqlite`. +- `/fhir`: FHIR R4 resource definitions, SMART discovery, and FHIR client logic. +- `/handlers`: HTTP request handlers (launch, auth, dashboard, sync) and template rendering. +- `/middleware`: Session management middleware (loading and hard-gate protection). +- `/models`: Core domain models and context keys. +- `/templates`: Embedded HTML templates with layout inheritance. ## Prerequisites @@ -44,24 +53,30 @@ The project is structured into modular packages under `/app`: Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/): - **App Launch URL:** `http://localhost:8080/launch` - **Redirect URL:** `http://localhost:8080/auth-redirect` - - The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox. + + The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox. ## Configuration -Configuration is currently managed in `app/main.go` via `config.AppConfig`. You can define multiple EHRs, set your redirect URI, and required scopes. +Configuration is currently managed in `app/main.go` via the `config.AppConfig` struct. You can define multiple EHRs, set your redirect URI, and required scopes directly in the code. ```go +// Example configuration in app/main.go cfg := &config.AppConfig{ DBPath: "fhir_sandbox.db", + Server: config.ServerConfig{ + Port: 8080, + }, SMART: config.SMARTConfig{ RedirectURL: "http://localhost:8080/auth-redirect", Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"}, }, EHRs: []config.EHRConfig{ { - Name: "SmartHealthIT Sandbox (R4)", - FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", - ClientID: "your-client-id", + Name: "SmartHealthIT Sandbox (R4)", + FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", + ClientID: "your-client-id", + ClientSecret: "your-client-secret", // Optional, depending on EHR }, }, } @@ -69,15 +84,23 @@ cfg := &config.AppConfig{ ## Testing -The project includes unit tests for database logic and FHIR parsing. +The project includes comprehensive unit tests for database logic, FHIR parsing, and clinical data handling. -```bash -go test ./... -``` +- **Run all tests:** + ```bash + go test ./... + ``` + +- **Run tests with coverage:** + ```bash + go test -cover ./... + ``` ## Future Improvements -- [ ] Support for Observations, Conditions, and Encounters. -- [ ] Move configuration to a YAML/TOML file. -- [ ] Add structured logging (slog). -- [ ] Implement Refresh Token handling. +- [ ] **Configuration Loading:** Implement a robust configuration loader (e.g., `spf13/viper`) to load settings from files or environment variables. +- [ ] **Structured Logging:** Migrate to Go 1.21's `log/slog` for structured, leveled logging. +- [ ] **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions. +- [ ] **Additional Resources:** Add support for Encounters, Procedures, Immunizations, etc. +- [ ] **Frontend Enhancement:** Evolve the UI with HTMX or a modern JS framework for better interactivity. +- [ ] **FHIR Type Safety:** Adopt a comprehensive FHIR library (e.g., `google/fhir/go`) for stricter type safety. diff --git a/app/handlers/handler.go b/app/handlers/handler.go index 4f920e3..307881d 100644 --- a/app/handlers/handler.go +++ b/app/handlers/handler.go @@ -172,10 +172,5 @@ func TemplateFuncs() template.FuncMap { } 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/main.go b/app/main.go index 9038050..baa3c0c 100644 --- a/app/main.go +++ b/app/main.go @@ -92,6 +92,7 @@ func main() { mux.HandleFunc("/", h.HandleRoot) mux.HandleFunc("/launch", h.HandleLaunch) mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect) + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("app/static")))) // Session-required routes — wrapped with the hard-gate middleware. mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard))) diff --git a/app/static/css/styles.css b/app/static/css/styles.css new file mode 100644 index 0000000..1da89c2 --- /dev/null +++ b/app/static/css/styles.css @@ -0,0 +1,453 @@ +:root { + /* Brand Colors */ + --primary-color: #2563EB; + --primary-hover: #1D4ED8; + --secondary-color: #0D9488; + --secondary-hover: #0F766E; + + /* State Colors */ + --success-color: #10B981; + --success-bg: #D1FAE5; + --warning-color: #F59E0B; + --warning-bg: #FEF3C7; + --danger-color: #EF4444; + --danger-bg: #FEE2E2; + --info-color: #3B82F6; + --info-bg: #DBEAFE; + + /* Neutral Colors */ + --background-color: #F3F4F6; + --surface-color: #FFFFFF; + --text-primary: #111827; + --text-secondary: #4B5563; + --text-muted: #9CA3AF; + --border-color: #E5E7EB; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + + /* Typography */ + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + /* Effects */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --radius: 0.5rem; + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Reset & Base */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background-color: var(--background-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + color: var(--text-primary); + line-height: 1.25; +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: var(--transition); +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-md); + width: 100%; +} + +main { + flex: 1; + padding: var(--spacing-xl) 0; +} + +/* Navbar */ +.navbar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: var(--spacing-md) 0; + position: sticky; + top: 0; + z-index: 50; + box-shadow: var(--shadow-sm); +} + +.navbar-content { + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.brand span { + font-weight: 400; + color: var(--text-primary); +} + +.nav-links { + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.nav-link { + color: var(--text-secondary); + font-weight: 500; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); +} + +.nav-link:hover { + background-color: var(--background-color); + color: var(--text-primary); + text-decoration: none; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: var(--transition); + font-size: 0.875rem; + gap: var(--spacing-sm); +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); + text-decoration: none; +} + +.btn-secondary { + background-color: var(--surface-color); + border-color: var(--border-color); + color: var(--text-secondary); +} + +.btn-secondary:hover { + background-color: var(--background-color); + text-decoration: none; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background-color: #DC2626; + text-decoration: none; +} + +.btn-outline { + background-color: transparent; + border-color: var(--border-color); + color: var(--text-secondary); +} + +.btn-outline:hover, .btn-outline.active { + background-color: var(--background-color); + border-color: var(--text-secondary); + color: var(--text-primary); + text-decoration: none; +} + +/* Cards */ +.card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + overflow: hidden; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.card-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* Grid System */ +.grid { + display: grid; + gap: var(--spacing-lg); +} + +.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } +.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } + +@media (max-width: 768px) { + .grid-cols-2, .grid-cols-3, .grid-cols-4 { + grid-template-columns: 1fr; + } +} + +/* Details List */ +.details-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.detail-item { + display: flex; + flex-direction: column; +} + +.detail-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 600; + margin-bottom: var(--spacing-xs); +} + +.detail-value { + font-size: 1rem; + font-weight: 500; + color: var(--text-primary); +} + +/* Tables */ +.table-container { + width: 100%; + overflow-x: auto; + border-radius: var(--radius); + border: 1px solid var(--border-color); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + background-color: var(--surface-color); +} + +th { + text-align: left; + padding: var(--spacing-md); + background-color: var(--background-color); + color: var(--text-secondary); + font-weight: 600; + border-bottom: 1px solid var(--border-color); + white-space: nowrap; +} + +td { + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + vertical-align: top; +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover td { + background-color: #F9FAFB; +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-success { background-color: var(--success-bg); color: var(--success-color); } +.badge-warning { background-color: var(--warning-bg); color: var(--warning-color); } +.badge-danger { background-color: var(--danger-bg); color: var(--danger-color); } +.badge-info { background-color: var(--info-bg); color: var(--info-color); } +.badge-neutral { background-color: var(--background-color); color: var(--text-secondary); border: 1px solid var(--border-color); } + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--spacing-lg); + overflow-x: auto; +} + +.tab-btn { + padding: var(--spacing-md) var(--spacing-lg); + border: none; + background: none; + cursor: pointer; + color: var(--text-secondary); + font-weight: 500; + border-bottom: 2px solid transparent; + transition: var(--transition); + white-space: nowrap; +} + +.tab-btn:hover { + color: var(--primary-color); +} + +.tab-btn.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.tab-content { + display: none; + animation: fadeIn 0.2s ease-in-out; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Avatar */ +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + font-size: 1.25rem; +} + +.avatar-practitioner { background-color: var(--secondary-color); } +.avatar-patient { background-color: var(--primary-color); } + +/* Utility */ +.text-right { text-align: right; } +.text-center { text-align: center; } +.mt-4 { margin-top: var(--spacing-md); } +.mb-4 { margin-bottom: var(--spacing-md); } +.w-full { width: 100%; } +.flex { display: flex; } +.gap-2 { gap: var(--spacing-sm); } +.gap-4 { gap: var(--spacing-md); } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.code-block { + font-family: var(--font-mono); + background-color: var(--background-color); + padding: var(--spacing-sm); + border-radius: var(--radius); + font-size: 0.75rem; + word-break: break-all; + color: var(--text-primary); +} + +/* Flash Message */ +.flash-message { + padding: var(--spacing-md); + border-radius: var(--radius); + margin-bottom: var(--spacing-lg); + display: flex; + justify-content: space-between; + align-items: center; +} + +.flash-success { + background-color: var(--success-bg); + border: 1px solid var(--success-color); + color: #065F46; +} + +/* Accordion/Details */ +details > summary { + list-style: none; + cursor: pointer; + padding: var(--spacing-sm) 0; + font-weight: 600; + color: var(--primary-color); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +details > summary::-webkit-details-marker { + display: none; +} + +details > summary::before { + content: '▶'; + font-size: 0.75rem; + transition: transform 0.2s; +} + +details[open] > summary::before { + transform: rotate(90deg); +} diff --git a/app/templates/base.html b/app/templates/base.html index 2213e4e..03b9420 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,233 +4,31 @@ FHIR Health Platform + + -
-
FHIR Health Platform
- + -
+
{{block "content" .}}{{end}}
-
- FHIR Health Platform — SMART on FHIR R4 +
+

FHIR Health Platform © 2026 — SMART on FHIR R4 Sandbox

{{block "scripts" .}}{{end}} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 50c066b..7278a82 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,471 +1,414 @@ {{template "base.html" .}} {{define "nav"}} - All Patients - Home + All Patients + Home
- +
{{end}} {{define "content"}} -{{/* ---- Success Flash Message ---- */}} +{{/* ---- Flash Messages ---- */}} {{if .Synced}} -
-
+
+
+
- ✓ Sync complete - Data has been refreshed from the EHR. + Sync complete + Data has been refreshed from the EHR.
-
+
{{end}} -{{/* ---- Practitioner block ---- */}} -{{if .Practitioner}} -
-
Logged-in Clinician
-
-
- {{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}} +
+ + {{/* ---- Left Column: Patient Context ---- */}} +
+ + {{/* Practitioner Card */}} + {{if .Practitioner}} +
+
+

Clinician

+ Active Session +
+
+
+ {{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}} +
+
+

+ {{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}} + {{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}} + {{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}} +

+ + {{if eq .Practitioner.Role "practitioner"}}Health Care Practitioner{{else}}Patient{{end}} + +
+
-
-

- {{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}} - {{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}} - {{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}} -

- {{if eq .Practitioner.Role "practitioner"}} - Health Care Practitioner - {{else}} - Patient (Self-Service) - {{end}} -
-
-
-
- -

{{formatDate .Practitioner.DOB}}

-
-
- -

{{titleCase .Practitioner.Gender}}

-
-
- -

{{orDash .Practitioner.Email}}

-
-
- -

{{.Practitioner.FHIRID}}

-
-
-
-{{else}} -
-

No practitioner context was returned by this EHR. Session not established.

-
-{{end}} + {{end}} -{{/* ---- Patient block ---- */}} -{{if .Patient}} -
-
Active Patient
-
-
- {{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}} + {{/* Patient Card */}} + {{if .Patient}} +
+
+

Patient

+ Active Context +
+ +
+
+ {{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}} +
+
+

+ {{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}} + {{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}} + {{if .Patient.LastName}}{{.Patient.LastName}}{{end}} +

+ ID: {{.Patient.FHIRID}} +
+
+ +
+
+ DOB + {{formatDate .Patient.DOB}} +
+
+ Gender + {{titleCase .Patient.Gender}} +
+
+ Email + {{orDash .Patient.Email}} +
+
-
-

- {{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}} - {{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}} - {{if .Patient.LastName}}{{.Patient.LastName}}{{end}} -

- Patient -
-
-
-
-
- -

{{formatDate .Patient.DOB}}

-
-
- -

{{titleCase .Patient.Gender}}

-
-
- -

{{orDash .Patient.Email}}

-
-
- -

{{.Patient.FHIRID}}

-
-
- -

{{.Patient.EHRURL}}

-
-
- -

{{.Patient.ID}}

-
-
-
-{{/* ---- High-Criticality Allergy Warning Card ---- */}} -{{if hasCriticalAllergies .Allergies}} -
-
- -
- High-Criticality Allergies Detected -

+ {{/* Alerts Card */}} + {{if hasCriticalAllergies .Allergies}} +

+
+

Critical Allergies

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

+
-
-
-{{end}} + {{end}} -{{/* ---- Clinical Data block ---- */}} -
-
-
Clinical Data
-
- {{if .LatestSync}} - - Last synced: {{formatDateTime .LatestSync.SyncedAt}} - - {{else}} - Never synced - {{end}} -
- -
+ {{/* SMART Inspector */}} +
+
+ SMART Inspector +
+
+ FHIR Base URL + {{.Session.EHRURL}} +
+
+ Scopes + {{.Session.Scope}} +
+
+ Access Token + {{.Session.AccessToken}} +
+
+
+ + {{end}}
-
- - - - - -
+ {{/* ---- Right Column: Clinical Data ---- */}} +
+ {{if .Patient}} +
+
+

Clinical Record

+
+ + {{if .LatestSync}}Synced: {{formatDateTime .LatestSync.SyncedAt}}{{else}}Never synced{{end}} + +
+ +
+
+
- {{/* ---- Observations Tab (Grouped by Category) ---- */}} -
- {{if .Observations}} - {{range $cat, $obs := groupByCategory .Observations}} -
-

{{titleCase $cat}}

- - - - - - - - - - - - {{range $obs}} - - - - - - - +
+ + + + + +
+ + {{/* Observations */}} +
+ {{if .Observations}} + {{range $cat, $obs := groupByCategory .Observations}} +
+

{{titleCase $cat}}

+
+
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}}
+ + + + + + + + + + + {{range $obs}} + + + + + + + + {{end}} + +
DateCodeValueInterpretationStatus
{{orDash .EffectiveDate}}{{orDash .CodeText}} + {{if .ValueQuantity}} + {{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}} + {{if or .ReferenceRangeLow .ReferenceRangeHigh}} +
+ Ref: [{{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.
+ {{end}} +
+ + {{/* Conditions */}} +
+ {{if .Conditions}} +
+ + + +
+
+ + + + + + + + + + + {{range .Conditions}} + + + + + + + {{end}} + +
DateConditionStatusVerification
{{orDash .RecordedDate}}{{orDash .CodeText}} + {{if eq .ClinicalStatus "active"}} + Active + {{else if eq .ClinicalStatus "resolved"}} + Resolved + {{else}} + {{.ClinicalStatus}} + {{end}} + {{titleCase .VerificationStatus}}
+
+ {{else}} +
No conditions found.
+ {{end}} +
+ + {{/* Medications */}} +
+ {{if .Medications}} +
+ + + + + + + + + + + + {{range .Medications}} + + + + + + + + {{end}} + +
AuthoredMedicationDosageStatusRequester
{{orDash .AuthoredOn}}{{orDash .MedCodeText}}{{orDash .DosageText}} + {{if eq .Status "active"}} + Active + {{else}} + {{.Status}} + {{end}} + {{orDash .RequesterDisplay}}
+
+ {{else}} +
No medications found.
+ {{end}} +
+ + {{/* Allergies */}} +
+ {{if .Allergies}} +
+ + + + + + + + + + + + {{range .Allergies}} + + + + + + + + {{end}} + +
DateAllergenTypeCriticalityStatus
{{orDash .RecordedDate}}{{orDash .CodeText}}{{titleCase .Type}} + {{if eq .Criticality "high"}} + High + {{else if eq .Criticality "medium"}} + Medium + {{else}} + {{orDash .Criticality}} + {{end}} + {{titleCase .ClinicalStatus}}
+
+ {{else}} +
No allergies found.
+ {{end}} +
+ + {{/* Notes */}} +
+ {{if .DocumentReferences}} +
+ + + + + + + + + + + + {{range .DocumentReferences}} + + + + + + + + {{end}} + +
DateTypeDescriptionStatusAction
{{orDash .Date}}{{orDash .TypeText}}{{orDash .Description}}{{orDash .Status}} + {{if .ContentURL}} + View + {{else}} + Inline + {{end}} +
+
+ {{else}} +
No clinical notes found.
+ {{end}} +
+
- {{end}} - {{else}} -

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

- {{end}} -
- - {{/* ---- Conditions Tab (with filter bar) ---- */}} - - - {{/* ---- Medications Tab ---- */}} - - - {{/* ---- Allergies Tab ---- */}} - - - {{/* ---- Clinical Notes Tab ---- */}} -
-{{/* ---- SMART Inspector Accordion ---- */}} -
- - SMART Inspector - -
-
-
- -

{{.Session.EHRURL}}

-
-
- -

{{.Session.Scope}}

-
-
- - {{.Session.AccessToken}} -
- {{if .Session.IDToken}} -
- - {{.Session.IDToken}} -
- {{end}} -
-
-
- -{{/* ---- Raw FHIR resource accordion ---- */}} -{{if .RawPatient}} -
- - View Raw FHIR Patient Resource - -
- - - - - - - - - - - - - - - - - - - - - - - - - {{range .RawPatient.Name}} - - - - - {{end}} - {{range .RawPatient.Telecom}} - - - - - {{end}} - {{range .RawPatient.Address}} - - - - - {{end}} - -
FieldValue
resourceType{{.RawPatient.ResourceTypeField}}
id{{.RawPatient.ID}}
gender{{.RawPatient.Gender}}
birthDate{{.RawPatient.BirthDate}}
name ({{.Use}}) - {{range .Given}}{{.}} {{end}}{{.Family}} -
telecom ({{.System}}){{.Value}}
address - {{range .Line}}{{.}}, {{end}}{{.City}}{{if .State}}, {{.State}}{{end}} {{.PostalCode}} -
-
-
{{end}} -{{end}}{{/* end if .Patient */}} - -{{end}}{{/* end content */}} - {{define "scripts"}} + {{end}} diff --git a/app/templates/error.html b/app/templates/error.html index 206f7d2..67e3b17 100644 --- a/app/templates/error.html +++ b/app/templates/error.html @@ -5,9 +5,16 @@ {{end}} {{define "content"}} -
-

{{.Code}}

-

{{.Message}}

- Go Home +
+
+ +
+

{{.Code}}

+

{{.Message}}

+ +
{{end}} diff --git a/app/templates/index.html b/app/templates/index.html index 7f20d12..8dfc4bf 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,15 +1,61 @@ {{template "base.html" .}} {{define "nav"}} - Home + Home + About SMART {{end}} {{define "content"}} -
-

FHIR Health Platform

-

A SMART on FHIR-powered platform for secure, context-aware EHR integration. Launch from your EHR to get started.

- - Launch with SmartHealthIT Sandbox - +
+

FHIR Health Platform

+

+ Secure, context-aware EHR integration powered by SMART on FHIR R4. + Seamlessly visualize patient data, sync records, and improve clinical workflows. +

+ + +
+ +
+
+
+ +
+

Secure Authentication

+

+ OAuth2 + OIDC integration ensures secure practitioner and patient context launch directly from the EHR. +

+
+ +
+
+ +
+

Real-time Vitals

+

+ Instant synchronization of Observations, Conditions, and Medications using FHIR R4 standards. +

+
+ +
+
+ +
+
+

Clinical Notes

+
+

+ Access DocumentReferences and clinical notes with built-in viewer support for PDF and text content. +

+
{{end}} diff --git a/app/templates/patients.html b/app/templates/patients.html index 2a383da..72af76d 100644 --- a/app/templates/patients.html +++ b/app/templates/patients.html @@ -1,12 +1,87 @@ {{template "base.html" .}} {{define "nav"}} - Dashboard + Dashboard
- +
{{end}} +{{define "content"}} + +
+

Patient Directory

+
+ EHR: {{.Session.EHRURL}} +
+
+ +
+
+

Synced Patients {{len .Patients}}

+
+ Patients synced from the EHR during this or previous sessions. +
+
+ +
+
+ +
+
+ + {{if .Patients}} +
+ + + + + + + + + + + + {{range .Patients}} + + + + + + + + {{end}} + +
NameDate of BirthGenderFHIR IDActions
+
+
+ {{if .FirstName}}{{slice .FirstName 0 1}}{{end}}{{if .LastName}}{{slice .LastName 0 1}}{{end}} +
+
+ {{if .FirstName}}{{.FirstName}} {{end}} + {{if .LastName}}{{.LastName}}{{end}} +
+
+
{{formatDate .DOB}}{{titleCase .Gender}}{{.FHIRID}} + + View Dashboard + +
+
+ {{else}} +
+

No patients have been synced yet.

+
+ {{end}} +
+ +{{end}} + {{define "scripts"}} {{end}} -{{define "content"}} - -
-

All Patients

-
- EHR: {{.Session.EHRURL}} -
-
- -
-
Synced Patients ({{len .Patients}})
-

The following patients have been synced from the EHR during this or previous sessions.

- - {{if .Patients}} -
- -
- - - - - - - - - - - - {{range .Patients}} - - - - - - - - {{end}} - -
NameDate of BirthGenderFHIR IDActions
- {{if .FirstName}}{{.FirstName}} {{end}} - {{if .LastName}}{{.LastName}}{{end}} - {{formatDate .DOB}}{{titleCase .Gender}}{{.FHIRID}} - - View Dashboard - -
- {{else}} -
-

No patients have been synced yet.

-
- {{end}} -
- -{{end}} From c25b6e0c803de91910426494576d4b4fc791ae5b Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 23:59:46 -0500 Subject: [PATCH 4/4] UI Upgrades --- app/db/clinical.go | 297 +++++++++++++- app/db/clinical_test.go | 408 +++++++++++++++++++ app/db/db.go | 102 ++++- app/fhir/fhir.go | 405 +++++++++++++++++-- app/fhir/fhir_test.go | 229 +++++++++++ app/handlers/dashboard.go | 73 +++- app/handlers/handler.go | 193 ++++++++- app/handlers/sync.go | 58 ++- app/models/models.go | 78 +++- app/static/css/styles.css | 681 +++++++++++++------------------ app/templates/base.html | 7 +- app/templates/dashboard.html | 756 ++++++++++++++++++----------------- app/templates/error.html | 2 +- app/templates/index.html | 2 +- app/templates/patients.html | 95 +++-- 15 files changed, 2494 insertions(+), 892 deletions(-) diff --git a/app/db/clinical.go b/app/db/clinical.go index a4748ef..521707a 100644 --- a/app/db/clinical.go +++ b/app/db/clinical.go @@ -401,22 +401,25 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, 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 = ? + patient_fhir_id = ?, + clinical_status = ?, + verification_status = ?, + type = ?, + category = ?, + criticality = ?, + code_text = ?, + code_system = ?, + code_code = ?, + recorded_date = ?, + reaction_severity = ?, + reaction_manifestation = ?, + 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, + a.RecordedDate, a.ReactionSeverity, a.ReactionManifestation, + now, existingID, ) if err != nil { return "", fmt.Errorf("db: update allergy_intolerance %s: %w", existingID, err) @@ -429,11 +432,13 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + code_text, code_system, code_code, recorded_date, + reaction_severity, reaction_manifestation, 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, + a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, + a.ReactionSeverity, a.ReactionManifestation, now, ) if err != nil { return "", fmt.Errorf("db: insert allergy_intolerance fhir_id=%s: %w", a.FHIRID, err) @@ -446,7 +451,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. 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 + code_text, code_system, code_code, recorded_date, + reaction_severity, reaction_manifestation, synced_at FROM allergy_intolerances WHERE patient_fhir_id = ? AND ehr_url = ? ORDER BY recorded_date DESC`, @@ -463,7 +469,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. 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, + &a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, + &a.ReactionSeverity, &a.ReactionManifestation, &a.SyncedAt, ); err != nil { return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err) } @@ -472,6 +479,262 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. return out, rows.Err() } +// --------------------------------------------------------------------------- +// Immunization +// --------------------------------------------------------------------------- + +// UpsertImmunization inserts or updates an Immunization record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertImmunization(imm *models.Immunization) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM immunizations WHERE fhir_id = ? AND ehr_url = ?`, + imm.FHIRID, imm.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE immunizations SET + patient_fhir_id = ?, + status = ?, + vaccine_text = ?, + vaccine_system = ?, + vaccine_code = ?, + occurrence_date = ?, + primary_source = ?, + lot_number = ?, + synced_at = ? + WHERE id = ?`, + imm.PatientFHIRID, imm.Status, + imm.VaccineText, imm.VaccineSystem, imm.VaccineCode, + imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update immunization %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO immunizations ( + id, fhir_id, ehr_url, patient_fhir_id, + status, vaccine_text, vaccine_system, vaccine_code, + occurrence_date, primary_source, lot_number, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, imm.FHIRID, imm.EHRURL, imm.PatientFHIRID, + imm.Status, imm.VaccineText, imm.VaccineSystem, imm.VaccineCode, + imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert immunization fhir_id=%s: %w", imm.FHIRID, err) + } + return id, nil +} + +// ListImmunizations returns all Immunizations for the given patient, newest first. +func (s *Store) ListImmunizations(patientFHIRID, ehrURL string) ([]models.Immunization, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, vaccine_text, vaccine_system, vaccine_code, + occurrence_date, primary_source, lot_number, synced_at + FROM immunizations + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY occurrence_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list immunizations: %w", err) + } + defer rows.Close() + + var out []models.Immunization + for rows.Next() { + var imm models.Immunization + if err := rows.Scan( + &imm.ID, &imm.FHIRID, &imm.EHRURL, &imm.PatientFHIRID, + &imm.Status, &imm.VaccineText, &imm.VaccineSystem, &imm.VaccineCode, + &imm.OccurrenceDate, &imm.PrimarySource, &imm.LotNumber, &imm.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan immunization: %w", err) + } + out = append(out, imm) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Procedure +// --------------------------------------------------------------------------- + +// UpsertProcedure inserts or updates a Procedure record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertProcedure(p *models.Procedure) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM procedures WHERE fhir_id = ? AND ehr_url = ?`, + p.FHIRID, p.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE procedures SET + patient_fhir_id = ?, + status = ?, + code_text = ?, + code_system = ?, + code_code = ?, + performed_date = ?, + reason_text = ?, + outcome = ?, + synced_at = ? + WHERE id = ?`, + p.PatientFHIRID, p.Status, + p.CodeText, p.CodeSystem, p.CodeCode, + p.PerformedDate, p.ReasonText, p.Outcome, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update procedure %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO procedures ( + id, fhir_id, ehr_url, patient_fhir_id, + status, code_text, code_system, code_code, + performed_date, reason_text, outcome, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, p.FHIRID, p.EHRURL, p.PatientFHIRID, + p.Status, p.CodeText, p.CodeSystem, p.CodeCode, + p.PerformedDate, p.ReasonText, p.Outcome, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert procedure fhir_id=%s: %w", p.FHIRID, err) + } + return id, nil +} + +// ListProcedures returns all Procedures for the given patient, newest first. +func (s *Store) ListProcedures(patientFHIRID, ehrURL string) ([]models.Procedure, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, code_text, code_system, code_code, + performed_date, reason_text, outcome, synced_at + FROM procedures + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY performed_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list procedures: %w", err) + } + defer rows.Close() + + var out []models.Procedure + for rows.Next() { + var p models.Procedure + if err := rows.Scan( + &p.ID, &p.FHIRID, &p.EHRURL, &p.PatientFHIRID, + &p.Status, &p.CodeText, &p.CodeSystem, &p.CodeCode, + &p.PerformedDate, &p.ReasonText, &p.Outcome, &p.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan procedure: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Encounter +// --------------------------------------------------------------------------- + +// UpsertEncounter inserts or updates an Encounter record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertEncounter(e *models.Encounter) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM encounters WHERE fhir_id = ? AND ehr_url = ?`, + e.FHIRID, e.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE encounters SET + patient_fhir_id = ?, + status = ?, + class = ?, + type_text = ?, + period_start = ?, + period_end = ?, + reason_text = ?, + synced_at = ? + WHERE id = ?`, + e.PatientFHIRID, e.Status, e.Class, e.TypeText, + e.PeriodStart, e.PeriodEnd, e.ReasonText, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update encounter %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO encounters ( + id, fhir_id, ehr_url, patient_fhir_id, + status, class, type_text, + period_start, period_end, reason_text, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, e.FHIRID, e.EHRURL, e.PatientFHIRID, + e.Status, e.Class, e.TypeText, + e.PeriodStart, e.PeriodEnd, e.ReasonText, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert encounter fhir_id=%s: %w", e.FHIRID, err) + } + return id, nil +} + +// ListEncounters returns all Encounters for the given patient, newest first. +func (s *Store) ListEncounters(patientFHIRID, ehrURL string) ([]models.Encounter, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, class, type_text, + period_start, period_end, reason_text, synced_at + FROM encounters + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY period_start DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list encounters: %w", err) + } + defer rows.Close() + + var out []models.Encounter + for rows.Next() { + var e models.Encounter + if err := rows.Scan( + &e.ID, &e.FHIRID, &e.EHRURL, &e.PatientFHIRID, + &e.Status, &e.Class, &e.TypeText, + &e.PeriodStart, &e.PeriodEnd, &e.ReasonText, &e.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan encounter: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + // --------------------------------------------------------------------------- // PatientSync // --------------------------------------------------------------------------- diff --git a/app/db/clinical_test.go b/app/db/clinical_test.go index a253def..0de6da6 100644 --- a/app/db/clinical_test.go +++ b/app/db/clinical_test.go @@ -271,6 +271,414 @@ func TestListDocumentReferences_Empty(t *testing.T) { } } +// --------------------------------------------------------------------------- +// MedicationRequest tests +// --------------------------------------------------------------------------- + +func TestUpsertMedicationRequest_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + med := &models.MedicationRequest{ + FHIRID: "med-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "active", + Intent: "order", + MedCodeText: "Metformin 500mg", + MedCodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm", + MedCodeCode: "860975", + AuthoredOn: "2024-01-10", + RequesterDisplay: "Dr. Smith", + DosageText: "1 tablet twice daily", + } + + id1, err := store.UpsertMedicationRequest(med) + if err != nil { + t.Fatalf("initial UpsertMedicationRequest: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + med.Status = "stopped" + id2, err := store.UpsertMedicationRequest(med) + if err != nil { + t.Fatalf("update UpsertMedicationRequest: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListMedicationRequests(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListMedicationRequests: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 med, got %d", len(rows)) + } + if rows[0].Status != "stopped" { + t.Errorf("Status: got %q, want stopped", rows[0].Status) + } +} + +func TestListMedicationRequests_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListMedicationRequests("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListMedicationRequests: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// AllergyIntolerance tests (including reaction fields) +// --------------------------------------------------------------------------- + +func TestUpsertAllergyIntolerance_WithReaction(t *testing.T) { + store := newTestStore(t) + + allergy := &models.AllergyIntolerance{ + FHIRID: "allergy-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + ClinicalStatus: "active", + VerificationStatus: "confirmed", + Type: "allergy", + Category: "medication", + Criticality: "high", + CodeText: "Penicillin", + CodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm", + CodeCode: "7980", + RecordedDate: "2018-05-01", + ReactionSeverity: "severe", + ReactionManifestation: "Anaphylaxis", + } + + id1, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("initial UpsertAllergyIntolerance: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + // Update reaction. + allergy.ReactionSeverity = "moderate" + allergy.ReactionManifestation = "Rash" + id2, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("update UpsertAllergyIntolerance: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListAllergyIntolerances: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 allergy, got %d", len(rows)) + } + got := rows[0] + if got.ReactionSeverity != "moderate" { + t.Errorf("ReactionSeverity: got %q, want moderate", got.ReactionSeverity) + } + if got.ReactionManifestation != "Rash" { + t.Errorf("ReactionManifestation: got %q, want Rash", got.ReactionManifestation) + } +} + +func TestUpsertAllergyIntolerance_NoReaction(t *testing.T) { + store := newTestStore(t) + + allergy := &models.AllergyIntolerance{ + FHIRID: "allergy-no-rxn", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + ClinicalStatus: "active", + CodeText: "Latex", + } + _, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("UpsertAllergyIntolerance (no reaction): %v", err) + } + + rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListAllergyIntolerances: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1, got %d", len(rows)) + } + if rows[0].ReactionSeverity != "" { + t.Errorf("expected empty ReactionSeverity, got %q", rows[0].ReactionSeverity) + } +} + +// --------------------------------------------------------------------------- +// Immunization tests +// --------------------------------------------------------------------------- + +func TestUpsertImmunization_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + imm := &models.Immunization{ + FHIRID: "imm-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + VaccineText: "Influenza, seasonal", + VaccineSystem: "http://hl7.org/fhir/sid/cvx", + VaccineCode: "141", + OccurrenceDate: "2023-10-01", + PrimarySource: true, + LotNumber: "LOT123", + } + + id1, err := store.UpsertImmunization(imm) + if err != nil { + t.Fatalf("initial UpsertImmunization: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + imm.LotNumber = "LOT456" + id2, err := store.UpsertImmunization(imm) + if err != nil { + t.Fatalf("update UpsertImmunization: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListImmunizations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 immunization, got %d", len(rows)) + } + if rows[0].LotNumber != "LOT456" { + t.Errorf("LotNumber: got %q, want LOT456", rows[0].LotNumber) + } + if !rows[0].PrimarySource { + t.Error("PrimarySource should be true") + } +} + +func TestListImmunizations_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListImmunizations("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +func TestListImmunizations_OrderedNewestFirst(t *testing.T) { + store := newTestStore(t) + + for _, item := range []struct { + id string + date string + }{ + {"imm-a", "2022-09-01"}, + {"imm-b", "2023-10-15"}, + {"imm-c", "2021-03-01"}, + } { + _, err := store.UpsertImmunization(&models.Immunization{ + FHIRID: item.id, + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + OccurrenceDate: item.date, + }) + if err != nil { + t.Fatalf("UpsertImmunization %s: %v", item.id, err) + } + } + + rows, err := store.ListImmunizations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3, got %d", len(rows)) + } + if rows[0].FHIRID != "imm-b" { + t.Errorf("first: got %q, want imm-b", rows[0].FHIRID) + } + if rows[2].FHIRID != "imm-c" { + t.Errorf("last: got %q, want imm-c", rows[2].FHIRID) + } +} + +// --------------------------------------------------------------------------- +// Procedure tests +// --------------------------------------------------------------------------- + +func TestUpsertProcedure_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + proc := &models.Procedure{ + FHIRID: "proc-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + CodeText: "Appendectomy", + CodeSystem: "http://snomed.info/sct", + CodeCode: "80146002", + PerformedDate: "2019-06-15", + ReasonText: "Acute appendicitis", + Outcome: "Successful procedure", + } + + id1, err := store.UpsertProcedure(proc) + if err != nil { + t.Fatalf("initial UpsertProcedure: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + proc.Outcome = "Procedure completed without complications" + id2, err := store.UpsertProcedure(proc) + if err != nil { + t.Fatalf("update UpsertProcedure: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListProcedures(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListProcedures: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 procedure, got %d", len(rows)) + } + if rows[0].Outcome != "Procedure completed without complications" { + t.Errorf("Outcome: got %q", rows[0].Outcome) + } +} + +func TestListProcedures_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListProcedures("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListProcedures: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// Encounter tests +// --------------------------------------------------------------------------- + +func TestUpsertEncounter_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + enc := &models.Encounter{ + FHIRID: "enc-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "finished", + Class: "AMB", + TypeText: "Office visit", + PeriodStart: "2024-03-10", + PeriodEnd: "2024-03-10", + ReasonText: "Annual physical", + } + + id1, err := store.UpsertEncounter(enc) + if err != nil { + t.Fatalf("initial UpsertEncounter: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + enc.Status = "cancelled" + id2, err := store.UpsertEncounter(enc) + if err != nil { + t.Fatalf("update UpsertEncounter: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListEncounters(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 encounter, got %d", len(rows)) + } + if rows[0].Status != "cancelled" { + t.Errorf("Status: got %q, want cancelled", rows[0].Status) + } + if rows[0].Class != "AMB" { + t.Errorf("Class: got %q, want AMB", rows[0].Class) + } +} + +func TestListEncounters_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListEncounters("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +func TestListEncounters_OrderedNewestFirst(t *testing.T) { + store := newTestStore(t) + + for _, item := range []struct { + id string + start string + }{ + {"enc-a", "2023-01-01"}, + {"enc-b", "2024-06-01"}, + {"enc-c", "2022-12-01"}, + } { + _, err := store.UpsertEncounter(&models.Encounter{ + FHIRID: item.id, + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "finished", + PeriodStart: item.start, + }) + if err != nil { + t.Fatalf("UpsertEncounter %s: %v", item.id, err) + } + } + + rows, err := store.ListEncounters(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3, got %d", len(rows)) + } + if rows[0].FHIRID != "enc-b" { + t.Errorf("first: got %q, want enc-b", rows[0].FHIRID) + } + if rows[2].FHIRID != "enc-c" { + t.Errorf("last: got %q, want enc-c", rows[2].FHIRID) + } +} + // --------------------------------------------------------------------------- // PatientSync tests // --------------------------------------------------------------------------- diff --git a/app/db/db.go b/app/db/db.go index dde2aea..075d246 100644 --- a/app/db/db.go +++ b/app/db/db.go @@ -239,12 +239,81 @@ var migrations = []migration{ 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: - // { - // version: 2, - // sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`, - // }, + { + version: 5, + sql: ` + ALTER TABLE allergy_intolerances ADD COLUMN reaction_severity TEXT NOT NULL DEFAULT ''; + ALTER TABLE allergy_intolerances ADD COLUMN reaction_manifestation TEXT NOT NULL DEFAULT ''; + `, + }, + { + version: 6, + sql: ` + CREATE TABLE IF NOT EXISTS immunizations ( + 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 '', + vaccine_text TEXT NOT NULL DEFAULT '', + vaccine_system TEXT NOT NULL DEFAULT '', + vaccine_code TEXT NOT NULL DEFAULT '', + occurrence_date TEXT NOT NULL DEFAULT '', + primary_source INTEGER NOT NULL DEFAULT 0, + lot_number TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_immunizations_patient ON immunizations(patient_fhir_id, ehr_url); + `, + }, + { + version: 7, + sql: ` + CREATE TABLE IF NOT EXISTS procedures ( + 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 '', + code_text TEXT NOT NULL DEFAULT '', + code_system TEXT NOT NULL DEFAULT '', + code_code TEXT NOT NULL DEFAULT '', + performed_date TEXT NOT NULL DEFAULT '', + reason_text TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_procedures_patient ON procedures(patient_fhir_id, ehr_url); + `, + }, + { + version: 8, + sql: ` + CREATE TABLE IF NOT EXISTS encounters ( + 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 '', + class TEXT NOT NULL DEFAULT '', + type_text TEXT NOT NULL DEFAULT '', + period_start TEXT NOT NULL DEFAULT '', + period_end TEXT NOT NULL DEFAULT '', + reason_text TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_encounters_patient ON encounters(patient_fhir_id, ehr_url); + `, + }, + { + version: 9, + sql: ` + ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT ''; + `, + }, } // migrate applies any migrations that have not yet been run, in order. @@ -315,6 +384,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { first_name = ?, middle_name = ?, last_name = ?, + mrn = ?, dob = ?, gender = ?, email = ?, @@ -322,7 +392,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { role = ?, updated_at = ? WHERE id = ?`, - u.FirstName, u.MiddleName, u.LastName, + u.FirstName, u.MiddleName, u.LastName, u.MRN, u.DOB, u.Gender, u.Email, u.FHIRResourceType, string(u.Role), now, existingID, @@ -342,11 +412,11 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { _, err = s.db.Exec(` INSERT INTO users ( id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role), - u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email, + u.FirstName, u.MiddleName, u.LastName, u.MRN, u.DOB, u.Gender, u.Email, now, now, ) if err != nil { @@ -361,13 +431,13 @@ func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) { u := &models.User{} err := s.db.QueryRow(` SELECT id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE fhir_id = ? AND ehr_url = ?`, fhirID, ehrURL, ).Scan( &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, - &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { @@ -381,12 +451,12 @@ func (s *Store) GetUserByID(id string) (*models.User, error) { u := &models.User{} err := s.db.QueryRow(` SELECT id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE id = ?`, id, ).Scan( &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, - &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { @@ -399,7 +469,7 @@ func (s *Store) GetUserByID(id string) (*models.User, error) { 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, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE role = ? AND ehr_url = ? ORDER BY last_name ASC, first_name ASC`, @@ -415,7 +485,7 @@ func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, 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.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, fmt.Errorf("db: scan user: %w", err) diff --git a/app/fhir/fhir.go b/app/fhir/fhir.go index 8cb864a..63d852f 100644 --- a/app/fhir/fhir.go +++ b/app/fhir/fhir.go @@ -114,6 +114,16 @@ type Meta struct { // https://www.hl7.org/fhir/patient.html // --------------------------------------------------------------------------- +// Extension represents the FHIR Extension data type (R4). +// Extensions are used for US Core race/ethnicity and other modifiers. +type Extension struct { + URL string `json:"url"` + ValueCode string `json:"valueCode,omitempty"` + ValueString string `json:"valueString,omitempty"` + ValueCoding *Coding `json:"valueCoding,omitempty"` + Extension []Extension `json:"extension,omitempty"` +} + // Patient represents a FHIR R4 Patient resource. // Fields are a curated subset of the full specification — add new fields // here as the platform needs them, without breaking existing code. @@ -129,6 +139,7 @@ type Patient struct { BirthDate string `json:"birthDate"` Address []Address `json:"address"` MaritalStatus CodeableConcept `json:"maritalStatus"` + Extension []Extension `json:"extension"` } // ResourceType implements the Resource interface. @@ -275,23 +286,87 @@ type MedicationRequest struct { func (m *MedicationRequest) ResourceType() string { return "MedicationRequest" } +// AllergyReaction represents a reaction event in a FHIR AllergyIntolerance. +type AllergyReaction struct { + Manifestation []CodeableConcept `json:"manifestation"` + Severity string `json:"severity"` // mild | moderate | severe +} + // 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"` + 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"` + Reaction []AllergyReaction `json:"reaction"` } func (a *AllergyIntolerance) ResourceType() string { return "AllergyIntolerance" } +// Period represents the FHIR Period data type (R4). +type Period struct { + Start string `json:"start"` + End string `json:"end"` +} + +// Immunization represents a FHIR R4 Immunization resource. +// https://www.hl7.org/fhir/immunization.html +type Immunization struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + VaccineCode CodeableConcept `json:"vaccineCode"` + Patient Reference `json:"patient"` + OccurrenceDateTime string `json:"occurrenceDateTime"` + PrimarySource bool `json:"primarySource"` + LotNumber string `json:"lotNumber"` +} + +func (i *Immunization) ResourceType() string { return "Immunization" } + +// Procedure represents a FHIR R4 Procedure resource. +// https://www.hl7.org/fhir/procedure.html +type Procedure struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Code CodeableConcept `json:"code"` + Subject Reference `json:"subject"` + PerformedDateTime string `json:"performedDateTime"` + PerformedPeriod *Period `json:"performedPeriod,omitempty"` + ReasonCode []CodeableConcept `json:"reasonCode"` + Outcome CodeableConcept `json:"outcome"` +} + +func (p *Procedure) ResourceType() string { return "Procedure" } + +// EncounterClass represents the coded class of an Encounter (V3 ActCode). +type EncounterClass struct { + Code string `json:"code"` +} + +// Encounter represents a FHIR R4 Encounter resource. +// https://www.hl7.org/fhir/encounter.html +type Encounter struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Class EncounterClass `json:"class"` + Type []CodeableConcept `json:"type"` + Subject Reference `json:"subject"` + Period *Period `json:"period,omitempty"` + ReasonCode []CodeableConcept `json:"reasonCode"` +} + +func (e *Encounter) ResourceType() string { return "Encounter" } + // Quantity represents the FHIR Quantity data type. type Quantity struct { Value float64 `json:"value"` @@ -565,7 +640,7 @@ func (c *Client) GetDocumentReferences(patientID, since string) ([]DocumentRefer // 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) + path := fmt.Sprintf("MedicationRequest?patient=%s&_sort=-date", patientID) if since != "" { path += fmt.Sprintf("&_lastUpdated=ge%s", since) } @@ -615,6 +690,87 @@ func (c *Client) GetAllergyIntolerances(patientID, since string) ([]AllergyIntol return allergies, nil } +// GetImmunizations fetches Immunization resources for a specific patient. +// If since is non-empty, only fetches immunizations modified after that timestamp (RFC3339). +func (c *Client) GetImmunizations(patientID, since string) ([]Immunization, error) { + var bundle Bundle + path := fmt.Sprintf("Immunization?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 immunizations []Immunization + for _, entry := range entries { + var imm Immunization + if err := json.Unmarshal(entry, &imm); err == nil { + immunizations = append(immunizations, imm) + } + } + return immunizations, nil +} + +// GetProcedures fetches Procedure resources for a specific patient. +// If since is non-empty, only fetches procedures modified after that timestamp (RFC3339). +func (c *Client) GetProcedures(patientID, since string) ([]Procedure, error) { + var bundle Bundle + path := fmt.Sprintf("Procedure?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 procedures []Procedure + for _, entry := range entries { + var p Procedure + if err := json.Unmarshal(entry, &p); err == nil { + procedures = append(procedures, p) + } + } + return procedures, nil +} + +// GetEncounters fetches Encounter resources for a specific patient. +// If since is non-empty, only fetches encounters modified after that timestamp (RFC3339). +func (c *Client) GetEncounters(patientID, since string) ([]Encounter, error) { + var bundle Bundle + path := fmt.Sprintf("Encounter?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 encounters []Encounter + for _, entry := range entries { + var e Encounter + if err := json.Unmarshal(entry, &e); err == nil { + encounters = append(encounters, e) + } + } + return encounters, nil +} + // GetSmartConfiguration fetches and parses the SMART discovery document // for this FHIR server. func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) { @@ -665,6 +821,30 @@ func primaryEmail(telecom []ContactPoint) string { return "" } +// extractMRN attempts to find a Medical Record Number in a FHIR Identifier slice. +// It looks for identifiers with a system containing "mrn" or the first identifier +// if none specifically match. +func extractMRN(identifiers []Identifier) string { + for _, id := range identifiers { + // Look for common MRN system patterns + system := strings.ToLower(id.System) + if strings.Contains(system, "mrn") || strings.Contains(system, "medical-record") { + return id.Value + } + // Also check the type code if present + for _, coding := range id.Type.Coding { + if strings.ToUpper(coding.Code) == "MR" { + return id.Value + } + } + } + // Fallback to the first identifier if we haven't found a definitive MRN + if len(identifiers) > 0 { + return identifiers[0].Value + } + return "" +} + // ExtractUserFromPatient converts a FHIR Patient resource into a platform // models.User with Role=RolePatient. The ehrURL is the originating FHIR // server base URL. @@ -685,6 +865,7 @@ func ExtractUserFromPatient(p *Patient, ehrURL string) *models.User { FirstName: first, MiddleName: middle, LastName: name.Family, + MRN: extractMRN(p.Identifier), DOB: p.BirthDate, Gender: p.Gender, Email: primaryEmail(p.Telecom), @@ -747,6 +928,20 @@ func firstCategoryText(cats []CodeableConcept) string { return "" } +// firstCategoryCode returns the first coding code of the first element in a +// []CodeableConcept. Preferred over firstCategoryText when machine-readable +// values (e.g. "problem-list-item", "vital-signs") are needed for filtering. +func firstCategoryCode(cats []CodeableConcept) string { + if len(cats) == 0 { + return "" + } + c := cats[0] + if len(c.Coding) > 0 && c.Coding[0].Code != "" { + return c.Coding[0].Code + } + return c.Text +} + // ExtractObservation maps a FHIR Observation to a models.Observation ready // for upsert. patientFHIRID and ehrURL are injected by the caller because // they are session-level context, not encoded inside the FHIR resource. @@ -830,6 +1025,8 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob } // ExtractCondition maps a FHIR Condition to a models.Condition ready for upsert. +// Category is stored as a machine-readable code (e.g. "problem-list-item", +// "encounter-diagnosis") for reliable client-side filtering. func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condition { coding := firstCoding(c.Code) clinicalStatus := firstCoding(c.ClinicalStatus) @@ -840,7 +1037,7 @@ func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condit PatientFHIRID: patientFHIRID, ClinicalStatus: clinicalStatus.Code, VerificationStatus: verificationStatus.Code, - Category: firstCategoryText(c.Category), + Category: firstCategoryCode(c.Category), CodeText: c.Code.Text, CodeSystem: coding.System, CodeCode: coding.Code, @@ -913,20 +1110,178 @@ func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL stri 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, + + // Extract first reaction severity and manifestation for safety display. + var reactionSeverity, reactionManifestation string + if len(a.Reaction) > 0 { + rxn := a.Reaction[0] + reactionSeverity = rxn.Severity + if len(rxn.Manifestation) > 0 { + m := rxn.Manifestation[0] + if m.Text != "" { + reactionManifestation = m.Text + } else if len(m.Coding) > 0 { + if m.Coding[0].Display != "" { + reactionManifestation = m.Coding[0].Display + } else { + reactionManifestation = m.Coding[0].Code + } + } + } } + + 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, + ReactionSeverity: reactionSeverity, + ReactionManifestation: reactionManifestation, + } +} + +// ExtractImmunization maps a FHIR Immunization to a models.Immunization ready for upsert. +func ExtractImmunization(imm *Immunization, patientFHIRID, ehrURL string) *models.Immunization { + coding := firstCoding(imm.VaccineCode) + return &models.Immunization{ + FHIRID: imm.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: imm.Status, + VaccineText: imm.VaccineCode.Text, + VaccineSystem: coding.System, + VaccineCode: coding.Code, + OccurrenceDate: imm.OccurrenceDateTime, + PrimarySource: imm.PrimarySource, + LotNumber: imm.LotNumber, + } +} + +// ExtractProcedure maps a FHIR Procedure to a models.Procedure ready for upsert. +func ExtractProcedure(p *Procedure, patientFHIRID, ehrURL string) *models.Procedure { + coding := firstCoding(p.Code) + + // Prefer performedDateTime; fall back to period start. + performedDate := p.PerformedDateTime + if performedDate == "" && p.PerformedPeriod != nil { + performedDate = p.PerformedPeriod.Start + } + + // First reason code text/display. + var reasonText string + if len(p.ReasonCode) > 0 { + rc := p.ReasonCode[0] + if rc.Text != "" { + reasonText = rc.Text + } else if len(rc.Coding) > 0 { + reasonText = rc.Coding[0].Display + } + } + + // Outcome text. + var outcome string + if p.Outcome.Text != "" { + outcome = p.Outcome.Text + } else if len(p.Outcome.Coding) > 0 { + outcome = p.Outcome.Coding[0].Display + } + + return &models.Procedure{ + FHIRID: p.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: p.Status, + CodeText: p.Code.Text, + CodeSystem: coding.System, + CodeCode: coding.Code, + PerformedDate: performedDate, + ReasonText: reasonText, + Outcome: outcome, + } +} + +// ExtractEncounter maps a FHIR Encounter to a models.Encounter ready for upsert. +func ExtractEncounter(e *Encounter, patientFHIRID, ehrURL string) *models.Encounter { + // Type text from first type entry. + var typeText string + if len(e.Type) > 0 { + t := e.Type[0] + if t.Text != "" { + typeText = t.Text + } else if len(t.Coding) > 0 { + typeText = t.Coding[0].Display + } + } + + var periodStart, periodEnd string + if e.Period != nil { + periodStart = e.Period.Start + periodEnd = e.Period.End + } + + // First reason code text/display. + var reasonText string + if len(e.ReasonCode) > 0 { + rc := e.ReasonCode[0] + if rc.Text != "" { + reasonText = rc.Text + } else if len(rc.Coding) > 0 { + reasonText = rc.Coding[0].Display + } + } + + return &models.Encounter{ + FHIRID: e.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: e.Status, + Class: e.Class.Code, + TypeText: typeText, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + ReasonText: reasonText, + } + +} + +// ExtractUSCoreRaceText returns the US Core race text extension value from a Patient, +// or empty string if not present. +func ExtractUSCoreRaceText(p *Patient) string { + const raceURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race" + for _, ext := range p.Extension { + if ext.URL == raceURL { + for _, nested := range ext.Extension { + if nested.URL == "text" { + return nested.ValueString + } + } + } + } + return "" +} + +// ExtractUSCoreEthnicityText returns the US Core ethnicity text extension value from a Patient, +// or empty string if not present. +func ExtractUSCoreEthnicityText(p *Patient) string { + const ethnicityURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity" + for _, ext := range p.Extension { + if ext.URL == ethnicityURL { + for _, nested := range ext.Extension { + if nested.URL == "text" { + return nested.ValueString + } + } + } + } + return "" } // ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference diff --git a/app/fhir/fhir_test.go b/app/fhir/fhir_test.go index ad8171c..980aff8 100644 --- a/app/fhir/fhir_test.go +++ b/app/fhir/fhir_test.go @@ -119,6 +119,235 @@ func TestExtractUserFromPractitioner_FullRecord(t *testing.T) { assertEqual(t, "Email", "dr.chen@hospital.org", u.Email) } +// --------------------------------------------------------------------------- +// ExtractAllergyIntolerance tests +// --------------------------------------------------------------------------- + +func TestExtractAllergyIntolerance_WithReaction(t *testing.T) { + a := &fhir.AllergyIntolerance{ + ID: "allergy-001", + Code: fhir.CodeableConcept{ + Text: "Penicillin", + Coding: []fhir.Coding{{System: "http://rxnorm", Code: "7980"}}, + }, + ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}}, + VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}}, + Criticality: "high", + RecordedDate: "2018-05-01", + Reaction: []fhir.AllergyReaction{ + { + Severity: "severe", + Manifestation: []fhir.CodeableConcept{ + {Text: "Anaphylaxis"}, + }, + }, + }, + } + + m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "ReactionSeverity", "severe", m.ReactionSeverity) + assertEqual(t, "ReactionManifestation", "Anaphylaxis", m.ReactionManifestation) + assertEqual(t, "ClinicalStatus", "active", m.ClinicalStatus) + assertEqual(t, "Criticality", "high", m.Criticality) +} + +func TestExtractAllergyIntolerance_NoReaction(t *testing.T) { + a := &fhir.AllergyIntolerance{ + ID: "allergy-002", + Code: fhir.CodeableConcept{Text: "Latex"}, + } + + m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir") + + if m.ReactionSeverity != "" { + t.Errorf("ReactionSeverity: expected empty, got %q", m.ReactionSeverity) + } + if m.ReactionManifestation != "" { + t.Errorf("ReactionManifestation: expected empty, got %q", m.ReactionManifestation) + } +} + +// --------------------------------------------------------------------------- +// ExtractImmunization tests +// --------------------------------------------------------------------------- + +func TestExtractImmunization_Full(t *testing.T) { + imm := &fhir.Immunization{ + ID: "imm-001", + Status: "completed", + VaccineCode: fhir.CodeableConcept{ + Text: "Influenza, seasonal", + Coding: []fhir.Coding{{System: "http://hl7.org/fhir/sid/cvx", Code: "141"}}, + }, + OccurrenceDateTime: "2023-10-01", + PrimarySource: true, + LotNumber: "LOT123", + } + + m := fhir.ExtractImmunization(imm, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "imm-001", m.FHIRID) + assertEqual(t, "Status", "completed", m.Status) + assertEqual(t, "VaccineText", "Influenza, seasonal", m.VaccineText) + assertEqual(t, "VaccineCode", "141", m.VaccineCode) + assertEqual(t, "OccurrenceDate", "2023-10-01", m.OccurrenceDate) + assertEqual(t, "LotNumber", "LOT123", m.LotNumber) + if !m.PrimarySource { + t.Error("PrimarySource should be true") + } +} + +// --------------------------------------------------------------------------- +// ExtractProcedure tests +// --------------------------------------------------------------------------- + +func TestExtractProcedure_WithDatetime(t *testing.T) { + p := &fhir.Procedure{ + ID: "proc-001", + Status: "completed", + Code: fhir.CodeableConcept{ + Text: "Appendectomy", + Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "80146002"}}, + }, + PerformedDateTime: "2019-06-15", + ReasonCode: []fhir.CodeableConcept{ + {Text: "Acute appendicitis"}, + }, + Outcome: fhir.CodeableConcept{Text: "Successful"}, + } + + m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "proc-001", m.FHIRID) + assertEqual(t, "Status", "completed", m.Status) + assertEqual(t, "CodeText", "Appendectomy", m.CodeText) + assertEqual(t, "PerformedDate", "2019-06-15", m.PerformedDate) + assertEqual(t, "ReasonText", "Acute appendicitis", m.ReasonText) + assertEqual(t, "Outcome", "Successful", m.Outcome) +} + +func TestExtractProcedure_FallsBackToPeriodStart(t *testing.T) { + p := &fhir.Procedure{ + ID: "proc-002", + Status: "completed", + Code: fhir.CodeableConcept{Text: "Colonoscopy"}, + PerformedPeriod: &fhir.Period{ + Start: "2022-03-01", + End: "2022-03-01", + }, + } + + m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir") + assertEqual(t, "PerformedDate (from period)", "2022-03-01", m.PerformedDate) +} + +// --------------------------------------------------------------------------- +// ExtractEncounter tests +// --------------------------------------------------------------------------- + +func TestExtractEncounter_Full(t *testing.T) { + e := &fhir.Encounter{ + ID: "enc-001", + Status: "finished", + Class: fhir.EncounterClass{Code: "AMB"}, + Type: []fhir.CodeableConcept{ + {Text: "Office visit"}, + }, + Period: &fhir.Period{Start: "2024-03-10", End: "2024-03-10"}, + ReasonCode: []fhir.CodeableConcept{ + {Text: "Annual physical"}, + }, + } + + m := fhir.ExtractEncounter(e, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "enc-001", m.FHIRID) + assertEqual(t, "Status", "finished", m.Status) + assertEqual(t, "Class", "AMB", m.Class) + assertEqual(t, "TypeText", "Office visit", m.TypeText) + assertEqual(t, "PeriodStart", "2024-03-10", m.PeriodStart) + assertEqual(t, "PeriodEnd", "2024-03-10", m.PeriodEnd) + assertEqual(t, "ReasonText", "Annual physical", m.ReasonText) +} + +// --------------------------------------------------------------------------- +// ExtractCondition category code tests +// --------------------------------------------------------------------------- + +func TestExtractCondition_UsesCategoryCode(t *testing.T) { + c := &fhir.Condition{ + ID: "cond-001", + Code: fhir.CodeableConcept{ + Text: "Hypertension", + Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "38341003"}}, + }, + ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}}, + VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}}, + Category: []fhir.CodeableConcept{ + { + Coding: []fhir.Coding{{System: "http://terminology.hl7.org/CodeSystem/condition-category", Code: "problem-list-item", Display: "Problem List Item"}}, + Text: "Problem List Item", + }, + }, + } + + m := fhir.ExtractCondition(c, "patient-001", "https://ehr.example.com/fhir") + + // Must store the code ("problem-list-item"), not the display text ("Problem List Item"). + assertEqual(t, "Category code", "problem-list-item", m.Category) +} + +// --------------------------------------------------------------------------- +// ExtractUSCoreRace/Ethnicity tests +// --------------------------------------------------------------------------- + +func TestExtractUSCoreRaceText(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-race", + Extension: []fhir.Extension{ + { + URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + Extension: []fhir.Extension{ + {URL: "text", ValueString: "White"}, + {URL: "ombCategory", ValueCoding: &fhir.Coding{Code: "2106-3", Display: "White"}}, + }, + }, + }, + } + + got := fhir.ExtractUSCoreRaceText(p) + if got != "White" { + t.Errorf("ExtractUSCoreRaceText: got %q, want White", got) + } +} + +func TestExtractUSCoreEthnicityText(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-eth", + Extension: []fhir.Extension{ + { + URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + Extension: []fhir.Extension{ + {URL: "text", ValueString: "Not Hispanic or Latino"}, + }, + }, + }, + } + + got := fhir.ExtractUSCoreEthnicityText(p) + if got != "Not Hispanic or Latino" { + t.Errorf("ExtractUSCoreEthnicityText: got %q, want Not Hispanic or Latino", got) + } +} + +func TestExtractUSCoreRaceText_Missing(t *testing.T) { + p := &fhir.Patient{ID: "patient-no-race"} + if got := fhir.ExtractUSCoreRaceText(p); got != "" { + t.Errorf("expected empty string for patient without race extension, got %q", got) + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/app/handlers/dashboard.go b/app/handlers/dashboard.go index 5833fdb..c541b24 100644 --- a/app/handlers/dashboard.go +++ b/app/handlers/dashboard.go @@ -3,12 +3,60 @@ package handlers import ( "log" "net/http" + "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" "github.com/AmanTahiliani/FHIR-Sandbox/app/models" ) +// ClinicalSummary holds pre-computed summary values for the Summary tab. +type ClinicalSummary struct { + LatestVitals []models.Observation + ActiveCondCount int + ActiveMedCount int + AbnormalLabCount int + AbnormalLabsRecent []models.Observation +} + +// buildClinicalSummary computes the clinical summary from existing in-memory data. +// No additional DB or FHIR calls are made. +func buildClinicalSummary(obs []models.Observation, conds []models.Condition, meds []models.MedicationRequest) ClinicalSummary { + // Latest value per vital code. + latestVitals := latestObsPerCode(filterObsByCategory(obs, "vital-signs")) + + activeConds := 0 + for _, c := range conds { + if c.ClinicalStatus == "active" { + activeConds++ + } + } + + activeMeds := 0 + for _, m := range meds { + if m.Status == "active" { + activeMeds++ + } + } + + // Abnormal labs within the last 30 days. + cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02") + var abnormalLabs []models.Observation + for _, o := range obs { + if isAbnormalInterp(o.Interpretation) && o.EffectiveDate >= cutoff { + abnormalLabs = append(abnormalLabs, o) + } + } + + return ClinicalSummary{ + LatestVitals: latestVitals, + ActiveCondCount: activeConds, + ActiveMedCount: activeMeds, + AbnormalLabCount: len(abnormalLabs), + AbnormalLabsRecent: abnormalLabs, + } +} + // HandleDashboard renders the stable patient dashboard. // 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 @@ -63,7 +111,6 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { observations, err := h.store.ListObservations(patientID, ehrURL) if err != nil { log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err) - // Non-fatal; render with empty slice. } conditions, err := h.store.ListConditions(patientID, ehrURL) @@ -86,6 +133,22 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { log.Printf("handlers: dashboard ListAllergyIntolerances Patient/%s: %v", patientID, err) } + immunizations, err := h.store.ListImmunizations(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListImmunizations Patient/%s: %v", patientID, err) + } + + procedures, err := h.store.ListProcedures(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListProcedures Patient/%s: %v", patientID, err) + } + + encounters, err := h.store.ListEncounters(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListEncounters Patient/%s: %v", patientID, err) + } + + summary := buildClinicalSummary(observations, conditions, medications) synced := r.URL.Query().Get("synced") == "true" h.render(w, "dashboard.html", dashboardData{ @@ -97,6 +160,10 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { DocumentReferences: docRefs, Medications: medications, Allergies: allergies, + Immunizations: immunizations, + Procedures: procedures, + Encounters: encounters, + Summary: summary, LatestSync: latestSync, Session: sess, Synced: synced, @@ -113,6 +180,10 @@ type dashboardData struct { DocumentReferences []models.DocumentReference Medications []models.MedicationRequest Allergies []models.AllergyIntolerance + Immunizations []models.Immunization + Procedures []models.Procedure + Encounters []models.Encounter + Summary ClinicalSummary LatestSync *models.PatientSync Session *models.Session Synced bool diff --git a/app/handlers/handler.go b/app/handlers/handler.go index 307881d..8ed5868 100644 --- a/app/handlers/handler.go +++ b/app/handlers/handler.go @@ -21,14 +21,18 @@ package handlers import ( "crypto/rand" "encoding/hex" + "fmt" "html/template" "io/fs" "log" "net/http" + "sort" + "strings" "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/config" "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/models" ) @@ -111,20 +115,74 @@ func generateState() (string, error) { return hex.EncodeToString(b), nil } -// templateFuncs returns the custom template function map. +// --------------------------------------------------------------------------- +// Private helpers used by both template funcs and buildClinicalSummary. +// --------------------------------------------------------------------------- + +// filterObsByCategory returns observations whose category matches cat +// using a normalised (lowercase, spaces→hyphens) comparison. +func filterObsByCategory(obs []models.Observation, cat string) []models.Observation { + want := strings.ToLower(strings.ReplaceAll(cat, " ", "-")) + var out []models.Observation + for _, o := range obs { + got := strings.ToLower(strings.ReplaceAll(o.Category, " ", "-")) + if got == want { + out = append(out, o) + } + } + return out +} + +// latestObsPerCode returns the most-recent observation per LOINC code (or code +// text when code is absent), sorted by code text for stable display. +func latestObsPerCode(obs []models.Observation) []models.Observation { + latest := make(map[string]models.Observation) + for _, o := range obs { + key := o.CodeCode + if key == "" { + key = o.CodeText + } + if existing, ok := latest[key]; !ok || o.EffectiveDate > existing.EffectiveDate { + latest[key] = o + } + } + out := make([]models.Observation, 0, len(latest)) + for _, o := range latest { + out = append(out, o) + } + sort.Slice(out, func(i, j int) bool { return out[i].CodeText < out[j].CodeText }) + return out +} + +// isAbnormalInterp returns true for interpretation codes that indicate an +// out-of-range or critical result. +func isAbnormalInterp(interp string) bool { + switch strings.ToUpper(strings.TrimSpace(interp)) { + case "H", "HH", "L", "LL", "A", "AA", "HIGH", "LOW", "ABNORMAL", "CRITICAL": + return true + } + return false +} + +// --------------------------------------------------------------------------- +// TemplateFuncs returns the custom template function map. // Defined here so it is available to both main.go (for wiring) and // handler tests. +// --------------------------------------------------------------------------- func TemplateFuncs() template.FuncMap { return template.FuncMap{ "formatDate": func(s string) string { if s == "" { return "—" } - t, err := time.Parse("2006-01-02", s) - if err != nil { - return s + // Try full datetime first (FHIR dateTime), then plain date. + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} { + t, err := time.Parse(layout, s) + if err == nil { + return t.Format("Jan 2, 2006") + } } - return t.Format("January 2, 2006") + return s }, "formatDateTime": func(t time.Time) string { if t.IsZero() { @@ -153,6 +211,7 @@ func TemplateFuncs() template.FuncMap { } return s }, + // groupByCategory is kept for backward compatibility. "groupByCategory": func(obs []models.Observation) map[string][]models.Observation { m := make(map[string][]models.Observation) for _, o := range obs { @@ -172,5 +231,129 @@ func TemplateFuncs() template.FuncMap { } return false }, + // --- T1.2 Vitals/Labs --- + "filterObsByCategory": func(obs []models.Observation, cat string) []models.Observation { + return filterObsByCategory(obs, cat) + }, + "latestObPerCode": func(obs []models.Observation) []models.Observation { + return latestObsPerCode(obs) + }, + "isAbnormal": func(interp string) bool { + return isAbnormalInterp(interp) + }, + // --- T1.3 Medication history --- + "filterMedsByStatus": func(meds []models.MedicationRequest, status string) []models.MedicationRequest { + if status == "" || status == "all" { + return meds + } + var out []models.MedicationRequest + for _, m := range meds { + if strings.EqualFold(m.Status, status) { + out = append(out, m) + } + } + return out + }, + // --- T1.1 Demographics --- + "calculateAge": func(dob string) string { + if dob == "" { + return "" + } + t, err := time.Parse("2006-01-02", dob) + if err != nil { + return "" + } + now := time.Now() + years := now.Year() - t.Year() + if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) { + years-- + } + return fmt.Sprintf("%d", years) + }, + "primaryPhone": func(p *fhir.Patient) string { + if p == nil { + return "" + } + for _, tc := range p.Telecom { + if tc.System == "phone" && tc.Value != "" { + return tc.Value + } + } + return "" + }, + "primaryAddress": func(p *fhir.Patient) string { + if p == nil || len(p.Address) == 0 { + return "" + } + addr := p.Address[0] + var parts []string + if len(addr.Line) > 0 { + parts = append(parts, addr.Line[0]) + } + if addr.City != "" { + parts = append(parts, addr.City) + } + if addr.State != "" { + parts = append(parts, addr.State) + } + if addr.PostalCode != "" { + parts = append(parts, addr.PostalCode) + } + return strings.Join(parts, ", ") + }, + "usRace": func(p *fhir.Patient) string { + if p == nil { + return "" + } + return fhir.ExtractUSCoreRaceText(p) + }, + "usEthnicity": func(p *fhir.Patient) string { + if p == nil { + return "" + } + return fhir.ExtractUSCoreEthnicityText(p) + }, + // --- T2.3 Encounters --- + "encounterClassBadge": func(class string) string { + switch strings.ToUpper(class) { + case "AMB": + return "badge-info" + case "EMER": + return "badge-danger" + case "IMP", "INPATIENT": + return "badge-warning" + default: + return "badge-neutral" + } + }, + "encounterClassLabel": func(class string) string { + switch strings.ToUpper(class) { + case "AMB": + return "Ambulatory" + case "EMER": + return "Emergency" + case "IMP": + return "Inpatient" + case "VR": + return "Virtual" + default: + if class == "" { + return "Visit" + } + return class + } + }, + "split": func(s, sep string) []string { + if s == "" { + return nil + } + return strings.Split(s, sep) + }, + "min": func(a, b int) int { + if a < b { + return a + } + return b + }, } } diff --git a/app/handlers/sync.go b/app/handlers/sync.go index 9fc44b3..fa105c5 100644 --- a/app/handlers/sync.go +++ b/app/handlers/sync.go @@ -143,6 +143,60 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { allergyCount++ } + // ----------------------------------------------------------------- + // Fetch Immunizations + // ----------------------------------------------------------------- + rawImmunizations, err := client.GetImmunizations(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetImmunizations for Patient/%s: %v", patientID, err) + } + + immunizationCount := 0 + for i := range rawImmunizations { + m := fhir.ExtractImmunization(&rawImmunizations[i], patientID, ehrURL) + if _, err := h.store.UpsertImmunization(m); err != nil { + log.Printf("handlers: sync UpsertImmunization fhir_id=%s: %v", m.FHIRID, err) + continue + } + immunizationCount++ + } + + // ----------------------------------------------------------------- + // Fetch Procedures + // ----------------------------------------------------------------- + rawProcedures, err := client.GetProcedures(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetProcedures for Patient/%s: %v", patientID, err) + } + + procedureCount := 0 + for i := range rawProcedures { + m := fhir.ExtractProcedure(&rawProcedures[i], patientID, ehrURL) + if _, err := h.store.UpsertProcedure(m); err != nil { + log.Printf("handlers: sync UpsertProcedure fhir_id=%s: %v", m.FHIRID, err) + continue + } + procedureCount++ + } + + // ----------------------------------------------------------------- + // Fetch Encounters + // ----------------------------------------------------------------- + rawEncounters, err := client.GetEncounters(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetEncounters for Patient/%s: %v", patientID, err) + } + + encounterCount := 0 + for i := range rawEncounters { + m := fhir.ExtractEncounter(&rawEncounters[i], patientID, ehrURL) + if _, err := h.store.UpsertEncounter(m); err != nil { + log.Printf("handlers: sync UpsertEncounter fhir_id=%s: %v", m.FHIRID, err) + continue + } + encounterCount++ + } + // ----------------------------------------------------------------- // Record the sync event // ----------------------------------------------------------------- @@ -150,8 +204,8 @@ 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 med=%d allergy=%d", - patientID, obsCount, condCount, docCount, medCount, allergyCount) + log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d imm=%d proc=%d enc=%d", + patientID, obsCount, condCount, docCount, medCount, allergyCount, immunizationCount, procedureCount, encounterCount) dashboardURL := "/dashboard?synced=true" if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { diff --git a/app/models/models.go b/app/models/models.go index 5eb4bfc..76e55d2 100644 --- a/app/models/models.go +++ b/app/models/models.go @@ -48,6 +48,7 @@ type User struct { FirstName string `json:"first_name" db:"first_name"` MiddleName string `json:"middle_name" db:"middle_name"` LastName string `json:"last_name" db:"last_name"` + MRN string `json:"mrn" db:"mrn"` // Medical Record Number DOB string `json:"dob" db:"dob"` // ISO 8601 date, e.g. "1990-04-22" Gender string `json:"gender" db:"gender"` // FHIR value set: male|female|other|unknown @@ -182,20 +183,69 @@ type MedicationRequest struct { // 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"` + 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"` + ReactionSeverity string `json:"reaction_severity" db:"reaction_severity"` + ReactionManifestation string `json:"reaction_manifestation" db:"reaction_manifestation"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Immunization is the persisted representation of a FHIR R4 Immunization. +type Immunization 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"` + VaccineText string `json:"vaccine_text" db:"vaccine_text"` + VaccineSystem string `json:"vaccine_system" db:"vaccine_system"` + VaccineCode string `json:"vaccine_code" db:"vaccine_code"` + OccurrenceDate string `json:"occurrence_date" db:"occurrence_date"` + PrimarySource bool `json:"primary_source" db:"primary_source"` + LotNumber string `json:"lot_number" db:"lot_number"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Procedure is the persisted representation of a FHIR R4 Procedure. +type Procedure 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"` + CodeText string `json:"code_text" db:"code_text"` + CodeSystem string `json:"code_system" db:"code_system"` + CodeCode string `json:"code_code" db:"code_code"` + PerformedDate string `json:"performed_date" db:"performed_date"` + ReasonText string `json:"reason_text" db:"reason_text"` + Outcome string `json:"outcome" db:"outcome"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Encounter is the persisted representation of a FHIR R4 Encounter. +type Encounter 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"` + Class string `json:"class" db:"class"` + TypeText string `json:"type_text" db:"type_text"` + PeriodStart string `json:"period_start" db:"period_start"` + PeriodEnd string `json:"period_end" db:"period_end"` + ReasonText string `json:"reason_text" db:"reason_text"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` } // PatientSync records a completed FHIR sync event for a patient. diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 1da89c2..68a58bf 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1,453 +1,324 @@ :root { - /* Brand Colors */ - --primary-color: #2563EB; - --primary-hover: #1D4ED8; - --secondary-color: #0D9488; - --secondary-hover: #0F766E; - - /* State Colors */ - --success-color: #10B981; - --success-bg: #D1FAE5; - --warning-color: #F59E0B; - --warning-bg: #FEF3C7; - --danger-color: #EF4444; - --danger-bg: #FEE2E2; - --info-color: #3B82F6; - --info-bg: #DBEAFE; - - /* Neutral Colors */ - --background-color: #F3F4F6; - --surface-color: #FFFFFF; - --text-primary: #111827; - --text-secondary: #4B5563; - --text-muted: #9CA3AF; - --border-color: #E5E7EB; - - /* Spacing */ - --spacing-xs: 0.25rem; - --spacing-sm: 0.5rem; - --spacing-md: 1rem; - --spacing-lg: 1.5rem; - --spacing-xl: 2rem; - - /* Typography */ - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* Ultra-refined Palette (Anthropic / Apple inspired) */ + --bg-app: #FCFCFC; /* Very subtle warm off-white for main background */ + --bg-panel: #FFFFFF; /* Pure white for cards */ + --bg-hover: #F4F4F5; /* Zinc 100 for subtle hovers */ - /* Effects */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --radius: 0.5rem; - --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Reset & Base */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; + --border-soft: #F4F4F5; /* Zinc 100 */ + --border-hard: #E4E4E7; /* Zinc 200 */ + --border-focus: #A1A1AA; /* Zinc 400 */ + + --text-main: #18181B; /* Zinc 900 - Almost black */ + --text-muted: #71717A; /* Zinc 500 - Refined gray */ + --text-faint: #A1A1AA; /* Zinc 400 - For placeholders/meta */ + + --brand-dark: #09090B; /* Anthropic style primary action */ + --brand-light: #F4F4F5; + + /* Semantic Colors - Muted & Sophisticated */ + --accent-blue: #0284C7; /* Sky 600 */ + --accent-blue-bg: #F0F9FF; /* Sky 50 */ + --danger: #E11D48; /* Rose 600 */ + --danger-bg: #FFF1F2; /* Rose 50 */ + --warning: #D97706; /* Amber 600 */ + --warning-bg: #FFFBEB; /* Amber 50 */ + --success: #059669; /* Emerald 600 */ + --success-bg: #ECFDF5; /* Emerald 50 */ + + /* Shapes & Metrics */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 9999px; + + /* Shadows - Apple-style diffused */ + --shadow-subtle: 0 2px 8px -2px rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.02); + --shadow-float: 0 12px 32px -4px rgba(0, 0, 0, 0.08), 0 4px 12px -2px rgba(0, 0, 0, 0.04); + + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; } +/* Reset & Typography */ +* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: var(--font-sans); - background-color: var(--background-color); - color: var(--text-primary); - line-height: 1.5; + background-color: var(--bg-app); + color: var(--text-main); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; min-height: 100vh; display: flex; flex-direction: column; } -h1, h2, h3, h4, h5, h6 { - font-weight: 600; - color: var(--text-primary); - line-height: 1.25; -} - -a { - color: var(--primary-color); - text-decoration: none; - transition: var(--transition); -} - -a:hover { - color: var(--primary-hover); - text-decoration: underline; -} +h1, h2, h3, h4 { font-weight: 600; letter-spacing: -0.02em; color: var(--text-main); line-height: 1.2; } +a { color: var(--text-main); text-decoration: none; transition: color 0.2s; } +a:hover { color: var(--text-muted); } /* Layout */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 var(--spacing-md); - width: 100%; -} - -main { - flex: 1; - padding: var(--spacing-xl) 0; -} +.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; width: 100%; } +main { flex: 1; padding: 3rem 0; } /* Navbar */ .navbar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: var(--spacing-md) 0; - position: sticky; - top: 0; - z-index: 50; - box-shadow: var(--shadow-sm); -} - -.navbar-content { - display: flex; - align-items: center; - justify-content: space-between; -} - -.brand { - font-size: 1.25rem; - font-weight: 700; - color: var(--primary-color); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.brand span { - font-weight: 400; - color: var(--text-primary); -} - -.nav-links { - display: flex; - gap: var(--spacing-md); - align-items: center; -} - -.nav-link { - color: var(--text-secondary); - font-weight: 500; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius); -} - -.nav-link:hover { - background-color: var(--background-color); - color: var(--text-primary); - text-decoration: none; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-hard); + position: sticky; top: 0; z-index: 50; + padding: 1rem 0; } +.navbar-content { display: flex; justify-content: space-between; align-items: center; } +.brand { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.03em; color: var(--text-main); display: flex; align-items: center; gap: 0.5rem; } +.brand .brand-light { font-weight: 400; color: var(--text-muted); } +.nav-links { display: flex; align-items: center; gap: 1.5rem; font-size: 0.875rem; font-weight: 500; } +.nav-link { color: var(--text-muted); } +.nav-link:hover { color: var(--text-main); } /* Buttons */ .btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius); - font-weight: 500; - cursor: pointer; - border: 1px solid transparent; - transition: var(--transition); - font-size: 0.875rem; - gap: var(--spacing-sm); + display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; + padding: 0.5rem 1rem; border-radius: var(--radius-sm); font-size: 0.875rem; font-weight: 500; + cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); border: 1px solid transparent; } +.btn-primary { background: var(--brand-dark); color: white; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } +.btn-primary:hover { background: #27272A; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); } +.btn-secondary, .btn-outline { background: white; color: var(--text-main); border-color: var(--border-hard); box-shadow: 0 1px 2px rgba(0,0,0,0.02); } +.btn-secondary:hover, .btn-outline:hover { background: var(--bg-hover); } +.btn-danger { background: var(--danger-bg); color: var(--danger); } +.btn-danger:hover { background: #FFE4E6; } +.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } +.btn-icon { padding: 0.375rem; border-radius: var(--radius-sm); } -.btn-primary { - background-color: var(--primary-color); - color: white; +/* Panels / Cards */ +.panel { + background: var(--bg-panel); border-radius: var(--radius-md); + border: 1px solid var(--border-soft); box-shadow: var(--shadow-subtle); } +.panel-padded { padding: 2rem; } -.btn-primary:hover { - background-color: var(--primary-hover); - text-decoration: none; -} - -.btn-secondary { - background-color: var(--surface-color); - border-color: var(--border-color); - color: var(--text-secondary); -} - -.btn-secondary:hover { - background-color: var(--background-color); - text-decoration: none; -} - -.btn-danger { - background-color: var(--danger-color); - color: white; -} - -.btn-danger:hover { - background-color: #DC2626; - text-decoration: none; -} - -.btn-outline { - background-color: transparent; - border-color: var(--border-color); - color: var(--text-secondary); -} - -.btn-outline:hover, .btn-outline.active { - background-color: var(--background-color); - border-color: var(--text-secondary); - color: var(--text-primary); - text-decoration: none; -} - -/* Cards */ -.card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: var(--spacing-lg); - margin-bottom: var(--spacing-lg); - overflow: hidden; -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-md); - padding-bottom: var(--spacing-md); - border-bottom: 1px solid var(--border-color); -} - -.card-title { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.card-subtitle { - font-size: 0.875rem; - color: var(--text-secondary); -} - -/* Grid System */ -.grid { - display: grid; - gap: var(--spacing-lg); -} - -.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } -.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } - -@media (max-width: 768px) { - .grid-cols-2, .grid-cols-3, .grid-cols-4 { - grid-template-columns: 1fr; - } -} - -/* Details List */ -.details-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: var(--spacing-md); -} - -.detail-item { - display: flex; - flex-direction: column; -} - -.detail-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - font-weight: 600; - margin-bottom: var(--spacing-xs); -} - -.detail-value { - font-size: 1rem; - font-weight: 500; - color: var(--text-primary); -} - -/* Tables */ -.table-container { - width: 100%; - overflow-x: auto; - border-radius: var(--radius); - border: 1px solid var(--border-color); -} - -table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; - background-color: var(--surface-color); -} - -th { - text-align: left; - padding: var(--spacing-md); - background-color: var(--background-color); - color: var(--text-secondary); - font-weight: 600; - border-bottom: 1px solid var(--border-color); - white-space: nowrap; -} - -td { - padding: var(--spacing-md); - border-bottom: 1px solid var(--border-color); - color: var(--text-secondary); - vertical-align: top; -} - -tr:last-child td { - border-bottom: none; -} - -tr:hover td { - background-color: #F9FAFB; +/* Typography Utilities */ +.text-main { color: var(--text-main); } +.text-muted { color: var(--text-muted); } +.text-faint { color: var(--text-faint); } +.text-danger { color: var(--danger); } +.text-success { color: var(--success); } +.text-blue { color: var(--accent-blue); } +.text-xs { font-size: 0.75rem; } +.text-sm { font-size: 0.875rem; } +.text-lg { font-size: 1.125rem; } +.text-xl { font-size: 1.25rem; } +.text-2xl { font-size: 1.5rem; letter-spacing: -0.03em; } +.text-3xl { font-size: 1.875rem; letter-spacing: -0.04em; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-mono { font-family: var(--font-mono); } +.uppercase { text-transform: uppercase; letter-spacing: 0.05em; } + +/* Layout Utilities */ +.flex { display: flex; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.flex-col { flex-direction: column; } +.gap-1 { gap: 0.25rem; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } +.gap-6 { gap: 1.5rem; } +.gap-8 { gap: 2rem; } +.w-full { width: 100%; } + +/* Avatars */ +.avatar { + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-full); font-weight: 600; + background: var(--bg-hover); color: var(--text-main); border: 1px solid var(--border-hard); } +.avatar-sm { width: 32px; height: 32px; font-size: 0.875rem; } +.avatar-md { width: 48px; height: 48px; font-size: 1.125rem; } +.avatar-lg { width: 80px; height: 80px; font-size: 2rem; background: var(--bg-panel); box-shadow: var(--shadow-subtle); border-color: var(--border-soft); } /* Badges */ .badge { - display: inline-flex; - align-items: center; - padding: 0.125rem 0.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - text-transform: capitalize; + display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; + border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 500; } +.badge-neutral { background: var(--bg-hover); color: var(--text-muted); border: 1px solid var(--border-hard); } +.badge-blue { background: var(--accent-blue-bg); color: var(--accent-blue); border: 1px solid #BAE6FD; } +.badge-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid #FECDD3; } +.badge-warning { background: var(--warning-bg); color: var(--warning); border: 1px solid #FDE68A; } +.badge-success { background: var(--success-bg); color: var(--success); border: 1px solid #A7F3D0; } -.badge-success { background-color: var(--success-bg); color: var(--success-color); } -.badge-warning { background-color: var(--warning-bg); color: var(--warning-color); } -.badge-danger { background-color: var(--danger-bg); color: var(--danger-color); } -.badge-info { background-color: var(--info-bg); color: var(--info-color); } -.badge-neutral { background-color: var(--background-color); color: var(--text-secondary); border: 1px solid var(--border-color); } - -/* Tabs */ -.tabs { - display: flex; - border-bottom: 1px solid var(--border-color); - margin-bottom: var(--spacing-lg); - overflow-x: auto; +/* Tables - Elegant & Airy */ +.table-wrapper { width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: separate; border-spacing: 0; } +th { + font-weight: 500; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; + letter-spacing: 0.05em; border-bottom: 1px solid var(--border-hard); + padding: 1rem 0.75rem; text-align: left; background: var(--bg-panel); } +td { + padding: 1.25rem 0.75rem; border-bottom: 1px solid var(--border-soft); + color: var(--text-main); font-size: 0.875rem; vertical-align: top; +} +tr:last-child td { border-bottom: none; } +tr:hover td { background-color: #FAFAFB; } +.td-sub { color: var(--text-muted); font-size: 0.8125rem; margin-top: 0.25rem; } +/* Tabs - Minimal Underline */ +.tabs { display: flex; gap: 2rem; border-bottom: 1px solid var(--border-hard); overflow-x: auto; scrollbar-width: none; } +.tabs::-webkit-scrollbar { display: none; } .tab-btn { - padding: var(--spacing-md) var(--spacing-lg); - border: none; - background: none; - cursor: pointer; - color: var(--text-secondary); - font-weight: 500; - border-bottom: 2px solid transparent; - transition: var(--transition); - white-space: nowrap; + background: none; border: none; padding: 0 0 1rem 0; margin-bottom: -1px; + color: var(--text-muted); font-size: 0.875rem; font-weight: 500; + border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.2s; white-space: nowrap; +} +.tab-btn:hover { color: var(--text-main); } +.tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); } +.tab-content { display: none; animation: fadeIn 0.3s ease; } +.tab-content.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + +/* Inputs / Search */ +.input-base { + width: 100%; padding: 0.625rem 1rem; border-radius: var(--radius-sm); + border: 1px solid var(--border-hard); background: var(--bg-panel); + color: var(--text-main); font-size: 0.875rem; outline: none; transition: border-color 0.2s; +} +.input-base:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px rgba(161, 161, 170, 0.1); } +.search-wrapper { position: relative; display: flex; align-items: center; max-width: 480px; } +.search-icon { position: absolute; left: 1rem; color: var(--text-faint); pointer-events: none; } +.search-input { padding-left: 2.75rem; border-radius: var(--radius-full); background: var(--bg-hover); border-color: transparent; } +.search-input:focus { background: var(--bg-panel); border-color: var(--border-hard); } + +/* Key-Value Lists */ +.kv-list { display: flex; flex-direction: column; gap: 0.75rem; } +.kv-row { display: flex; flex-direction: column; gap: 0.125rem; } +.kv-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; } +.kv-value { font-size: 0.875rem; color: var(--text-main); } + +/* Animation Utils */ +.animate-spin { animation: spin 1s linear infinite; } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +/* Grid Utilities */ +.grid { display: grid; } +.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 1024px) { + .lg\:flex-col { flex-direction: column; } + .lg\:grid-cols-1 { grid-template-columns: minmax(0, 1fr); } } -.tab-btn:hover { - color: var(--primary-color); +/* Page Headers */ +.page-header { margin-bottom: 2rem; } +.page-title { font-size: 1.875rem; font-weight: 600; color: var(--text-main); margin: 0; letter-spacing: -0.04em; } +.page-subtitle { font-size: 1rem; color: var(--text-muted); margin-top: 0.25rem; } + +/* Dashboard Specific Layout */ +.dashboard-layout { display: flex; gap: 1.5rem; align-items: flex-start; } +.dashboard-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1.5rem; } +.dashboard-sidebar { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 1rem; } +@media (max-width: 1024px) { + .dashboard-layout { flex-direction: column; } + .dashboard-sidebar { width: 100%; order: 2; } + .dashboard-main { order: 1; } } -.tab-btn.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); +/* Custom UI Components for App */ +.flash-message { + background: var(--success-bg); color: var(--success); + padding: 1rem; border-radius: var(--radius-md); border: 1px solid #A7F3D0; + display: flex; align-items: center; justify-content: space-between; } -.tab-content { - display: none; - animation: fadeIn 0.2s ease-in-out; +.mrn-badge { + background: var(--bg-hover); color: var(--text-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); + font-size: 0.875rem; font-weight: 500; font-family: var(--font-mono); border: 1px solid var(--border-hard); } -.tab-content.active { - display: block; -} +.patient-meta-strip { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem; } +.meta-item { font-size: 0.875rem; color: var(--text-muted); } +.meta-item strong { color: var(--text-main); font-weight: 500; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} +.stat-card { text-align: center; } +.stat-value { font-size: 2rem; font-weight: 600; line-height: 1; margin-bottom: 0.25rem; letter-spacing: -0.04em; } +.stat-label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } +.stat-card.danger { border-top: 3px solid var(--danger); } -/* Avatar */ -.avatar { - width: 48px; - height: 48px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - color: white; - font-size: 1.25rem; -} +/* Vitals strip & grid */ +.vitals-strip { display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; } +.vital-item { min-width: 140px; background: var(--bg-hover); padding: 1rem; border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 0.25rem; border: 1px solid var(--border-soft); } +.vital-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vital-value { font-size: 1.25rem; font-weight: 600; color: var(--text-main); } +.vital-value small { font-size: 0.75rem; font-weight: 500; color: var(--text-muted); } +.vital-date { font-size: 0.65rem; color: var(--text-muted); } +.vital-abnormal { background: var(--danger-bg); border-color: #FECDD3; } +.vital-abnormal .vital-value { color: var(--danger); } -.avatar-practitioner { background-color: var(--secondary-color); } -.avatar-patient { background-color: var(--primary-color); } +.vitals-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } -/* Utility */ -.text-right { text-align: right; } +.alert-card { border-left: 3px solid var(--danger); } +.alert-item { display: flex; flex-direction: column; gap: 0.125rem; padding: 0.5rem 0; } +.alert-code { font-weight: 600; color: var(--danger); font-size: 0.875rem; } +.alert-detail { font-size: 0.75rem; color: var(--text-muted); } + +.inspector-card { background: var(--bg-hover); border: 1px dashed var(--border-focus); } +.inspector-content { margin-top: 1rem; display: flex; flex-direction: column; gap: 1rem; } +.inspect-item .label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); display: block; margin-bottom: 0.25rem; } +.scopes-cloud { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.scope-tag { font-size: 0.75rem; background: var(--bg-panel); border: 1px solid var(--border-hard); padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); color: var(--text-muted); font-family: var(--font-mono); } + +.practitioner-mini-card { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-hover); padding: 0.25rem 0.75rem 0.25rem 0.25rem; border-radius: var(--radius-full); border: 1px solid var(--border-soft); } +.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); font-style: italic; } +.row-abnormal { background: var(--danger-bg); } + +/* Note cards */ +.notes-list { display: flex; flex-direction: column; gap: 1rem; } +.note-item { display: flex; flex-direction: column; gap: 0.5rem; } +.note-header { display: flex; align-items: center; gap: 0.75rem; } +.note-date { font-weight: 500; font-size: 0.875rem; color: var(--text-main); } +.note-type { font-size: 0.875rem; color: var(--text-muted); } +.note-desc { font-size: 0.875rem; color: var(--text-main); line-height: 1.5; } + +/* Directory toolbar */ +.directory-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: var(--bg-hover); border-bottom: 1px solid var(--border-hard); } + +/* Additional Utilities */ .text-center { text-align: center; } -.mt-4 { margin-top: var(--spacing-md); } -.mb-4 { margin-bottom: var(--spacing-md); } -.w-full { width: 100%; } -.flex { display: flex; } -.gap-2 { gap: var(--spacing-sm); } -.gap-4 { gap: var(--spacing-md); } -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.code-block { - font-family: var(--font-mono); - background-color: var(--background-color); - padding: var(--spacing-sm); - border-radius: var(--radius); - font-size: 0.75rem; - word-break: break-all; - color: var(--text-primary); -} +.text-right { text-align: right; } +.pt-4 { padding-top: 1rem; } +.pb-4 { padding-bottom: 1rem; } +.py-16 { padding-top: 4rem; padding-bottom: 4rem; } +.mt-4 { margin-top: 1rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-6 { margin-bottom: 1.5rem; } +.min-h-\[400px\] { min-height: 400px; } +.tracking-wider { letter-spacing: 0.05em; } +.break-all { word-break: break-all; } -/* Flash Message */ -.flash-message { - padding: var(--spacing-md); - border-radius: var(--radius); - margin-bottom: var(--spacing-lg); - display: flex; - justify-content: space-between; - align-items: center; -} +/* Tailwind-like utilities used in templates */ +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.border-b { border-bottom-width: 1px; border-bottom-style: solid; } +.border-hard { border-color: var(--border-hard); } +.bg-panel { background-color: var(--bg-panel); } -.flash-success { - background-color: var(--success-bg); - border: 1px solid var(--success-color); - color: #065F46; -} - -/* Accordion/Details */ -details > summary { - list-style: none; - cursor: pointer; - padding: var(--spacing-sm) 0; - font-weight: 600; - color: var(--primary-color); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -details > summary::-webkit-details-marker { - display: none; -} - -details > summary::before { - content: '▶'; - font-size: 0.75rem; - transition: transform 0.2s; -} - -details[open] > summary::before { - transform: rotate(90deg); -} +/* Additional Spacing & Layout Utilities */ +.m-0 { margin: 0; } +.p-0 { padding: 0; } +.p-5 { padding: 1.25rem; } +.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.shrink-0 { flex-shrink: 0; } +.overflow-hidden { overflow: hidden; } +.cursor-pointer { cursor: pointer; } +.outline-none { outline: 2px solid transparent; outline-offset: 2px; } +.max-w-\[500px\] { max-width: 500px; } +.align-middle { vertical-align: middle; } +.border-l-brand { border-left: 6px solid var(--brand-dark); } +.border-b { border-bottom-width: 1px; border-bottom-style: solid; } +.py-8 { padding-top: 2rem; padding-bottom: 2rem; } diff --git a/app/templates/base.html b/app/templates/base.html index 03b9420..a482ad9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,16 +6,13 @@ FHIR Health Platform -
-