mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
Merge pull request #1 from AmanTahiliani/smart-launch-enhancements
Smart launch enhancements
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -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/
|
||||||
171
AGENTS.md
Normal file
171
AGENTS.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# 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., `FHIRClient`).
|
||||||
|
- **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 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 <token>` 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
|
||||||
|
|
||||||
|
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 (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 (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. 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 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.*
|
||||||
164
README.md
164
README.md
@@ -1,86 +1,106 @@
|
|||||||
# 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, serving as a robust starting point for healthcare applications.
|
||||||
|
|
||||||
## Overview
|
## Features
|
||||||
|
|
||||||
This application implements a SMART on FHIR client that can:
|
- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone) with OAuth2 code exchange.
|
||||||
- Launch from an EHR context
|
- **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses.
|
||||||
- Authenticate using OAuth 2.0
|
- **Comprehensive FHIR Sync:** Automatically synchronizes and persists key clinical data:
|
||||||
- Retrieve patient information from FHIR servers
|
- Patient Demographics
|
||||||
- Display patient details in a structured format
|
- 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`:
|
||||||
|
|
||||||
|
- `/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
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.16 or higher
|
- **Go 1.24+**
|
||||||
- Access to a SMART on FHIR-compatible EHR system
|
- **No external database required** (uses embedded SQLite)
|
||||||
- Client credentials (client ID and secret) from your EHR system
|
|
||||||
|
## 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
|
## 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 the `config.AppConfig` struct. You can define multiple EHRs, set your redirect URI, and required scopes directly in the code.
|
||||||
|
|
||||||
1. SMART App settings:
|
```go
|
||||||
```go
|
// Example configuration in app/main.go
|
||||||
SMARTAppConfig {
|
cfg := &config.AppConfig{
|
||||||
redirectPath: "http://localhost:8080/auth-redirect",
|
DBPath: "fhir_sandbox.db",
|
||||||
clientID: "your-client-id",
|
Server: config.ServerConfig{
|
||||||
clientSecret: "your-client-secret",
|
Port: 8080,
|
||||||
scopes: []string{"launch", "patient/*.read"},
|
},
|
||||||
}
|
SMART: config.SMARTConfig{
|
||||||
2. EHR Client settings:
|
RedirectURL: "http://localhost:8080/auth-redirect",
|
||||||
```go
|
Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"},
|
||||||
EHRClientsConfig {
|
},
|
||||||
name: "EHR_NAME",
|
EHRs: []config.EHRConfig{
|
||||||
url: "https://your-ehr-fhir-endpoint.com",
|
{
|
||||||
authType: "auth-2",
|
Name: "SmartHealthIT Sandbox (R4)",
|
||||||
clientID: "your-ehr-client-id",
|
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||||
clientSecret: "your-ehr-client-secret",
|
ClientID: "your-client-id",
|
||||||
patientAPI: true,
|
ClientSecret: "your-client-secret", // Optional, depending on EHR
|
||||||
}
|
},
|
||||||
```
|
},
|
||||||
## Installation
|
}
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone github.com/AmanTahiliani/fhir-sandbox.git
|
|
||||||
cd fhir-sandbox
|
|
||||||
```
|
```
|
||||||
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
|
## Testing
|
||||||
|
|
||||||
You can test the application using a FHIR server that supports SMART on FHIR. Ensure you have the necessary credentials and configuration.
|
The project includes comprehensive unit tests for database logic, FHIR parsing, and clinical data handling.
|
||||||
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
|
- **Run all tests:**
|
||||||
- Add the redirect URL
|
```bash
|
||||||
- Add the client ID and secret
|
go test ./...
|
||||||
- Add the scopes
|
```
|
||||||
- Set the same client ID and secret in the application
|
|
||||||
|
- **Run tests with coverage:**
|
||||||
|
```bash
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
|||||||
80
app/config/config.go
Normal file
80
app/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
786
app/db/clinical.go
Normal file
786
app/db/clinical.go
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
// 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 = ?,
|
||||||
|
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 {
|
||||||
|
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, 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, o.Interpretation,
|
||||||
|
o.ReferenceRangeLow, o.ReferenceRangeHigh, 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, interpretation,
|
||||||
|
ref_range_low, ref_range_high, 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.Interpretation,
|
||||||
|
&o.ReferenceRangeLow, &o.ReferenceRangeHigh, &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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 = ?,
|
||||||
|
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, a.ReactionSeverity, a.ReactionManifestation,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
a.ReactionSeverity, a.ReactionManifestation, 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,
|
||||||
|
reaction_severity, reaction_manifestation, 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.ReactionSeverity, &a.ReactionManifestation, &a.SyncedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
753
app/db/clinical_test.go
Normal file
753
app/db/clinical_test.go
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
567
app/db/db.go
Normal file
567
app/db/db.go
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
// 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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
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 = ?,
|
||||||
|
mrn = ?,
|
||||||
|
dob = ?,
|
||||||
|
gender = ?,
|
||||||
|
email = ?,
|
||||||
|
fhir_resource_type = ?,
|
||||||
|
role = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
u.FirstName, u.MiddleName, u.LastName, u.MRN,
|
||||||
|
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, mrn, 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.MRN, 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, 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.MRN, &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, 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.MRN, &u.DOB, &u.Gender, &u.Email,
|
||||||
|
&u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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, mrn, 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.MRN, &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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
295
app/db/db_test.go
Normal file
295
app/db/db_test.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1309
app/fhir/fhir.go
Normal file
1309
app/fhir/fhir.go
Normal file
File diff suppressed because it is too large
Load Diff
360
app/fhir/fhir_test.go
Normal file
360
app/fhir/fhir_test.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, field, want, got string) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s: got %q, want %q", field, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/handlers/auth.go
Normal file
258
app/handlers/auth.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// 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=<auth_code>&state=<state_token>
|
||||||
|
// 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/<id>" (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=<authorization_code>&state=<state_token>
|
||||||
|
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
|
||||||
|
}
|
||||||
195
app/handlers/dashboard.go
Normal file
195
app/handlers/dashboard.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
medications, err := h.store.ListMedicationRequests(patientID, ehrURL)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Patient: patientUser,
|
||||||
|
Practitioner: practitionerUser,
|
||||||
|
RawPatient: patient,
|
||||||
|
Observations: observations,
|
||||||
|
Conditions: conditions,
|
||||||
|
DocumentReferences: docRefs,
|
||||||
|
Medications: medications,
|
||||||
|
Allergies: allergies,
|
||||||
|
Immunizations: immunizations,
|
||||||
|
Procedures: procedures,
|
||||||
|
Encounters: encounters,
|
||||||
|
Summary: summary,
|
||||||
|
LatestSync: latestSync,
|
||||||
|
Session: sess,
|
||||||
|
Synced: synced,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnauthorized redirects to root for dashboard requests.
|
||||||
|
func (h *Handler) handleUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
359
app/handlers/handler.go
Normal file
359
app/handlers/handler.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
// 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"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 "—"
|
||||||
|
}
|
||||||
|
// 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 s
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
// --- 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/handlers/launch.go
Normal file
177
app/handlers/launch.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// launch.go handles the SMART on FHIR EHR launch initiation sequence.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. EHR calls GET /launch?iss=<fhir_base>&launch=<opaque_token>
|
||||||
|
// 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=<fhir_base_url>&launch=<opaque_launch_token>
|
||||||
|
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)
|
||||||
|
}
|
||||||
32
app/handlers/logout.go
Normal file
32
app/handlers/logout.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
40
app/handlers/patients.go
Normal file
40
app/handlers/patients.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
215
app/handlers/sync.go
Normal file
215
app/handlers/sync.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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, 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.
|
||||||
|
//
|
||||||
|
// 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.MethodGet && 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
|
||||||
|
|
||||||
|
// 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, sinceTime)
|
||||||
|
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, sinceTime)
|
||||||
|
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, sinceTime)
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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++
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
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 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 != "" {
|
||||||
|
dashboardURL += "&patient_id=" + overrideID
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dashboardURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
451
app/main.go
451
app/main.go
@@ -1,370 +1,123 @@
|
|||||||
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"time"
|
||||||
"strings"
|
|
||||||
|
"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 {
|
//go:embed templates/*.html
|
||||||
config *ApplicationConfig
|
var embeddedTemplates embed.FS
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Printf("Starting EHR integration application...")
|
// -------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
appConfig := &ApplicationConfig{
|
// -------------------------------------------------------------------------
|
||||||
smart: &SMARTAppConfig{
|
cfg := &config.AppConfig{
|
||||||
redirectPath: "http://localhost:8080/auth-redirect",
|
DBPath: "fhir_sandbox.db",
|
||||||
clientID: "my-smart-client-id",
|
Server: config.ServerConfig{
|
||||||
clientSecret: "my-smart-client-secret",
|
Port: 8080,
|
||||||
scopes: []string{"launch", "patient/*.read"},
|
|
||||||
oAuth1: true,
|
|
||||||
oAuth2: false,
|
|
||||||
},
|
},
|
||||||
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",
|
Name: "SmartHealthIT Sandbox (R4)",
|
||||||
url: "https://launch.smarthealthit.org/v/r4/fhir",
|
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||||
authType: "auth-2",
|
ClientID: "abcdefghijklmnopqrst",
|
||||||
clientID: "abcdefghijklmnopqrst",
|
ClientSecret: "ehr_a_client_secret",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
|
// 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)
|
// Handlers & middleware
|
||||||
http.HandleFunc("/launch", app.handleLaunch)
|
// -------------------------------------------------------------------------
|
||||||
http.HandleFunc("/auth-redirect", app.handleAuthRedirect)
|
h := handlers.New(store, cfg, templateFS, handlers.TemplateFuncs())
|
||||||
|
sessionMW := middleware.NewSessionMiddleware(store)
|
||||||
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
// -------------------------------------------------------------------------
|
||||||
}
|
// Routes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
// handleRoot is a method of Application that handles the root endpoint.
|
mux := http.NewServeMux()
|
||||||
func (app *Application) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
// Public routes
|
||||||
w.Write([]byte("Welcome to the EHR integration application!"))
|
mux.HandleFunc("/", h.HandleRoot)
|
||||||
}
|
mux.HandleFunc("/launch", h.HandleLaunch)
|
||||||
|
mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect)
|
||||||
func getWellKnownUrl(iss string) (string, error) {
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("app/static"))))
|
||||||
wellKnownUrl := fmt.Sprintf("%s/.well-known/smart-configuration", iss)
|
|
||||||
resp, err := http.Get(wellKnownUrl)
|
// Session-required routes — wrapped with the hard-gate middleware.
|
||||||
if err != nil {
|
mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard)))
|
||||||
return "", err
|
mux.Handle("/dashboard/sync", sessionMW.RequireSession(http.HandlerFunc(h.HandleSync)))
|
||||||
}
|
mux.Handle("/patients", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatients)))
|
||||||
defer resp.Body.Close()
|
mux.Handle("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout)))
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("failed to fetch well-known URL")
|
// Apply the soft session loader to every request so templates can always
|
||||||
}
|
// read the current user from context.
|
||||||
|
root := sessionMW.LoadSession(mux)
|
||||||
var bodyBytes []byte
|
|
||||||
bodyBytes, err = io.ReadAll(resp.Body)
|
// -------------------------------------------------------------------------
|
||||||
if err != nil {
|
// Server
|
||||||
return "", err
|
// -------------------------------------------------------------------------
|
||||||
}
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
return string(bodyBytes), nil
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
}
|
Handler: root,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
// handleLaunch processes the SMART on FHIR launch request.
|
WriteTimeout: 30 * time.Second,
|
||||||
func (app *Application) handleLaunch(w http.ResponseWriter, r *http.Request) {
|
IdleTimeout: 60 * time.Second,
|
||||||
launchID := r.URL.Query().Get("launch")
|
}
|
||||||
iss := r.URL.Query().Get("iss")
|
|
||||||
// Log the query parameters and body data for debugging
|
log.Printf("main: starting FHIR platform on http://localhost%s", addr)
|
||||||
log.Printf("Query parameters: %v", r.URL.Query())
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
if launchID == "" || iss == "" {
|
log.Fatalf("main: server error: %v", err)
|
||||||
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><head><style>table,th,td {border: 1px solid black; border-collapse: collapse; padding: 5px;}</style></head><body>"
|
|
||||||
html += "<h2>Patient Details</h2><table>"
|
|
||||||
|
|
||||||
// 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 += "<tr>"
|
|
||||||
rows += fmt.Sprintf("<td><strong>%s%s</strong></td>", indent, key)
|
|
||||||
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]interface{}:
|
|
||||||
rows += "<td>" + buildTableRows(v, indent+" ") + "</td>"
|
|
||||||
case []interface{}:
|
|
||||||
rows += "<td><table>"
|
|
||||||
for _, item := range v {
|
|
||||||
if m, ok := item.(map[string]interface{}); ok {
|
|
||||||
rows += buildTableRows(m, indent+" ")
|
|
||||||
} else {
|
|
||||||
rows += fmt.Sprintf("<tr><td>%v</td></tr>", item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows += "</table></td>"
|
|
||||||
default:
|
|
||||||
rows += fmt.Sprintf("<td>%v</td>", v)
|
|
||||||
}
|
|
||||||
rows += "</tr>"
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
html += buildTableRows(patientDetails, "")
|
|
||||||
html += "</table></body></html>"
|
|
||||||
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
}
|
||||||
|
|||||||
162
app/middleware/session.go
Normal file
162
app/middleware/session.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
260
app/models/models.go
Normal file
260
app/models/models.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// 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"`
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
324
app/static/css/styles.css
Normal file
324
app/static/css/styles.css
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
:root {
|
||||||
|
/* 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 */
|
||||||
|
|
||||||
|
--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(--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 { 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: 1280px; margin: 0 auto; padding: 0 2rem; width: 100%; }
|
||||||
|
main { flex: 1; padding: 3rem 0; }
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
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; 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); }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
|
||||||
|
/* 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: 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; }
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
|
|
||||||
|
.vitals-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
33
app/templates/base.html
Normal file
33
app/templates/base.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FHIR Health Platform</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="navbar">
|
||||||
|
<div class="container navbar-content">
|
||||||
|
<div class="brand">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
FHIR <span class="brand-light">Sandbox</span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
{{block "nav" .}}{{end}}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="w-full text-center text-xs text-muted py-8">
|
||||||
|
<p>FHIR Health Platform © 2026 — SMART on FHIR R4 Sandbox</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
473
app/templates/dashboard.html
Normal file
473
app/templates/dashboard.html
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
{{template "base.html" .}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/patients" class="nav-link">All Patients</a>
|
||||||
|
<a href="/" class="nav-link">Home</a>
|
||||||
|
{{if .Practitioner}}
|
||||||
|
<div class="practitioner-mini-card">
|
||||||
|
<div class="avatar avatar-sm">
|
||||||
|
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium">Dr. {{.Practitioner.LastName}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form action="/logout" method="POST" class="flex items-center">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
|
||||||
|
{{/* ---- Flash Messages ---- */}}
|
||||||
|
{{if .Synced}}
|
||||||
|
<div class="flash-message mb-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
<div>
|
||||||
|
<strong class="font-medium text-main">Sync complete</strong>
|
||||||
|
<span class="text-sm text-muted">Data has been refreshed from the EHR.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="this.parentElement.style.display='none';" class="btn btn-icon text-muted">×</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Patient}}
|
||||||
|
<div class="flex flex-col gap-6 pt-4">
|
||||||
|
{{/* ---- Top Header: Patient Banner ---- */}}
|
||||||
|
<div class="panel panel-padded flex items-center justify-between border-l-brand">
|
||||||
|
<div class="flex items-center gap-6 w-full">
|
||||||
|
<div class="avatar avatar-lg">
|
||||||
|
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 w-full">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="page-title m-0">
|
||||||
|
{{.Patient.LastName}}, {{.Patient.FirstName}} {{.Patient.MiddleName}}
|
||||||
|
</h1>
|
||||||
|
<span class="mrn-badge">MRN: {{orDash .Patient.MRN}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="patient-meta-strip">
|
||||||
|
<span class="meta-item"><strong>DOB:</strong> {{formatDate .Patient.DOB}} ({{calculateAge .Patient.DOB}} yrs)</span>
|
||||||
|
<span class="meta-item"><strong>Gender:</strong> {{titleCase .Patient.Gender}}</span>
|
||||||
|
{{with .Patient.Email}}<span class="meta-item"><strong>Email:</strong> {{.}}</span>{{end}}
|
||||||
|
{{with primaryPhone .RawPatient}}<span class="meta-item"><strong>Phone:</strong> {{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" class="flex items-center">
|
||||||
|
<button class="btn btn-primary" type="submit" id="syncBtn" onclick="onSyncClick()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
|
Refresh Chart
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-layout">
|
||||||
|
{{/* ---- Main Content Column ---- */}}
|
||||||
|
<div class="dashboard-main">
|
||||||
|
|
||||||
|
{{/* Clinical Summary Stats */}}
|
||||||
|
<div class="grid grid-cols-3 gap-4 lg:grid-cols-1">
|
||||||
|
<div class="panel panel-padded stat-card">
|
||||||
|
<div class="stat-value text-blue">{{.Summary.ActiveCondCount}}</div>
|
||||||
|
<div class="stat-label">Active Conditions</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-padded stat-card">
|
||||||
|
<div class="stat-value text-success">{{.Summary.ActiveMedCount}}</div>
|
||||||
|
<div class="stat-label">Active Medications</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-padded stat-card {{if gt .Summary.AbnormalLabCount 0}}danger{{end}}">
|
||||||
|
<div class="stat-value {{if gt .Summary.AbnormalLabCount 0}}text-danger{{end}}">{{.Summary.AbnormalLabCount}}</div>
|
||||||
|
<div class="stat-label">Abnormal Labs (30d)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Clinical Data Tabs */}}
|
||||||
|
<div class="panel p-0 overflow-hidden">
|
||||||
|
<div class="bg-panel px-4 pt-4 border-b border-hard">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'vitals')">Vitals ({{len (latestObPerCode (filterObsByCategory .Observations "vital-signs"))}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'labs')">Labs ({{len (filterObsByCategory .Observations "laboratory")}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'medications')">Meds ({{len .Medications}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'allergies')">Allergies ({{len .Allergies}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'notes')">Notes ({{len .DocumentReferences}})</button>
|
||||||
|
<button class="tab-btn" onclick="openTab(event, 'more')" title="More Data">...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-padded min-h-[400px]">
|
||||||
|
{{/* ---- Summary Tab ---- */}}
|
||||||
|
<div id="summary" class="tab-content active">
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Latest Vital Signs</h3>
|
||||||
|
{{if .Summary.LatestVitals}}
|
||||||
|
<div class="vitals-strip">
|
||||||
|
{{range .Summary.LatestVitals}}
|
||||||
|
<div class="vital-item {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
|
||||||
|
<span class="vital-label" title="{{.CodeText}}">{{orDash .CodeText}}</span>
|
||||||
|
<span class="vital-value">
|
||||||
|
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <small>{{.ValueUnit}}</small>
|
||||||
|
{{else if .ValueString}}{{.ValueString}}
|
||||||
|
{{else}}—{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="vital-date">{{formatDate .EffectiveDate}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No vital signs recorded.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Recent Abnormal Labs</h3>
|
||||||
|
{{if .Summary.AbnormalLabsRecent}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Test</th><th>Result</th><th>Flag</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Summary.AbnormalLabsRecent}}
|
||||||
|
<tr class="row-abnormal">
|
||||||
|
<td>{{formatDate .EffectiveDate}}</td>
|
||||||
|
<td class="font-medium">{{.CodeText}}</td>
|
||||||
|
<td>{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}{{else}}{{.ValueString}}{{end}}</td>
|
||||||
|
<td><span class="badge badge-danger">{{.Interpretation}}</span></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No abnormal labs in the last 30 days.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Vitals Tab ---- */}}
|
||||||
|
<div id="vitals" class="tab-content">
|
||||||
|
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}}
|
||||||
|
{{if $vitals}}
|
||||||
|
<div class="vitals-grid">
|
||||||
|
{{range $vitals}}
|
||||||
|
<div class="panel panel-padded p-5 {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<span class="text-xs font-semibold text-muted">{{orDash .CodeText}}</span>
|
||||||
|
{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-semibold mb-4">
|
||||||
|
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <span class="text-sm font-medium text-muted">{{.ValueUnit}}</span>
|
||||||
|
{{else if .ValueString}}{{.ValueString}}
|
||||||
|
{{else}}—{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-muted">
|
||||||
|
<span>{{formatDate .EffectiveDate}}</span>
|
||||||
|
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
||||||
|
<span>Ref: {{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}-{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No vital signs found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Labs Tab ---- */}}
|
||||||
|
<div id="labs" class="tab-content">
|
||||||
|
{{$labs := filterObsByCategory .Observations "laboratory"}}
|
||||||
|
{{if $labs}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Test</th><th>Result</th><th>Ref Range</th><th>Flag</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $labs}}
|
||||||
|
<tr {{if isAbnormal .Interpretation}}class="row-abnormal"{{end}}>
|
||||||
|
<td>{{formatDate .EffectiveDate}}</td>
|
||||||
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .ValueQuantity}}<strong>{{printf "%.4g" (derefFloat64 .ValueQuantity)}}</strong> {{.ValueUnit}}
|
||||||
|
{{else if .ValueString}}{{.ValueString}}
|
||||||
|
{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-muted">
|
||||||
|
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
||||||
|
[{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}} – {{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
|
||||||
|
{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{else if .Interpretation}}<span class="badge badge-neutral">{{.Interpretation}}</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No lab results found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Conditions Tab ---- */}}
|
||||||
|
<div id="conditions" class="tab-content">
|
||||||
|
{{if .Conditions}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Onset</th><th>Condition</th><th>Status</th><th>Verification</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Conditions}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatDate (or .OnsetDate .RecordedDate)}}</td>
|
||||||
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
|
<td>{{if eq .ClinicalStatus "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.ClinicalStatus}}</span>{{end}}</td>
|
||||||
|
<td>{{titleCase .VerificationStatus}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No conditions found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Medications Tab ---- */}}
|
||||||
|
<div id="medications" class="tab-content">
|
||||||
|
{{if .Medications}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Authored</th><th>Medication</th><th>Dosage</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Medications}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatDate .AuthoredOn}}</td>
|
||||||
|
<td class="font-medium">{{orDash .MedCodeText}}</td>
|
||||||
|
<td>{{orDash .DosageText}}</td>
|
||||||
|
<td>{{if eq .Status "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.Status}}</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No medications found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Allergies Tab ---- */}}
|
||||||
|
<div id="allergies" class="tab-content">
|
||||||
|
{{if .Allergies}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Allergen</th><th>Reaction</th><th>Severity</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Allergies}}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
|
<td>{{orDash .ReactionManifestation}}</td>
|
||||||
|
<td>{{if eq .ReactionSeverity "severe"}}<span class="badge badge-danger">Severe</span>{{else if .ReactionSeverity}}<span class="badge badge-warning">{{.ReactionSeverity}}</span>{{else}}—{{end}}</td>
|
||||||
|
<td>{{titleCase .ClinicalStatus}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No allergies found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Notes Tab ---- */}}
|
||||||
|
<div id="notes" class="tab-content">
|
||||||
|
{{if .DocumentReferences}}
|
||||||
|
<div class="notes-list">
|
||||||
|
{{range .DocumentReferences}}
|
||||||
|
<div class="panel panel-padded note-item">
|
||||||
|
<div class="note-header">
|
||||||
|
<span class="note-date">{{formatDate .Date}}</span>
|
||||||
|
<span class="note-type">{{orDash .TypeText}}</span>
|
||||||
|
{{if .ContentURL}}<a href="{{.ContentURL}}" target="_blank" class="btn btn-outline btn-sm">View Document</a>{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="note-desc">{{orDash .Description}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No clinical notes found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- More Tab (Procedures, Immunizations, Encounters) ---- */}}
|
||||||
|
<div id="more" class="tab-content">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h4 class="font-semibold text-sm text-main">Recent Procedures</h4>
|
||||||
|
{{if .Procedures}}
|
||||||
|
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||||||
|
{{range (slice .Procedures 0 (min (len .Procedures) 5))}}
|
||||||
|
<li><strong class="text-main">{{formatDate .PerformedDate}}:</strong> {{.CodeText}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h4 class="font-semibold text-sm text-main">Recent Immunizations</h4>
|
||||||
|
{{if .Immunizations}}
|
||||||
|
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||||||
|
{{range (slice .Immunizations 0 (min (len .Immunizations) 5))}}
|
||||||
|
<li><strong class="text-main">{{formatDate .OccurrenceDate}}:</strong> {{.VaccineText}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h4 class="font-semibold text-sm text-main">Recent Encounters</h4>
|
||||||
|
{{if .Encounters}}
|
||||||
|
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||||||
|
{{range (slice .Encounters 0 (min (len .Encounters) 5))}}
|
||||||
|
<li><strong class="text-main">{{formatDate .PeriodStart}}:</strong> {{or .TypeText "Visit"}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Sidebar Column ---- */}}
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
|
||||||
|
{{/* High Priority: Alerts */}}
|
||||||
|
{{if hasCriticalAllergies .Allergies}}
|
||||||
|
<div class="panel panel-padded alert-card">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||||
|
<h3 class="font-semibold text-danger">Critical Alerts</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{{range .Allergies}}
|
||||||
|
{{if eq .Criticality "high"}}
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="alert-code">{{.CodeText}} Allergy</span>
|
||||||
|
{{with .ReactionManifestation}}<span class="alert-detail">{{.}}{{with $.ReactionSeverity}} ({{.}}){{end}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Patient Details (Expanded) */}}
|
||||||
|
<div class="panel panel-padded">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-semibold text-main">Patient Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="kv-list">
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Full Name</span>
|
||||||
|
<span class="kv-value font-medium">{{.Patient.FirstName}} {{.Patient.MiddleName}} {{.Patient.LastName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">FHIR ID</span>
|
||||||
|
<span class="kv-value text-xs font-mono">{{.Patient.FHIRID}}</span>
|
||||||
|
</div>
|
||||||
|
{{with primaryAddress .RawPatient}}
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Address</span>
|
||||||
|
<span class="kv-value">{{.}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{with usRace .RawPatient}}
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Race</span>
|
||||||
|
<span class="kv-value">{{.}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{with usEthnicity .RawPatient}}
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Ethnicity</span>
|
||||||
|
<span class="kv-value">{{.}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Low Priority: SMART Inspector */}}
|
||||||
|
<div class="panel panel-padded inspector-card">
|
||||||
|
<details>
|
||||||
|
<summary class="text-xs text-muted font-medium uppercase tracking-wider cursor-pointer outline-none">System Inspector</summary>
|
||||||
|
<div class="inspector-content mt-4">
|
||||||
|
<div class="inspect-item">
|
||||||
|
<span class="label">EHR Server</span>
|
||||||
|
<code class="text-xs text-muted break-all" title="{{.Session.EHRURL}}">{{.Session.EHRURL}}</code>
|
||||||
|
</div>
|
||||||
|
<div class="inspect-item">
|
||||||
|
<span class="label">Granted Scopes</span>
|
||||||
|
<div class="scopes-cloud">
|
||||||
|
{{range (split .Session.Scope " ")}}
|
||||||
|
<span class="scope-tag">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
function openTab(evt, tabName) {
|
||||||
|
var tabcontent = document.getElementsByClassName("tab-content");
|
||||||
|
for (var i = 0; i < tabcontent.length; i++) {
|
||||||
|
tabcontent[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
var tablinks = document.getElementsByClassName("tab-btn");
|
||||||
|
for (var i = 0; i < tablinks.length; i++) {
|
||||||
|
tablinks[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
var target = document.getElementById(tabName);
|
||||||
|
setTimeout(() => target.classList.add("active"), 10);
|
||||||
|
evt.currentTarget.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSyncClick() {
|
||||||
|
var btn = document.getElementById("syncBtn");
|
||||||
|
setTimeout(function() {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> Syncing...`;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for splitting scopes string
|
||||||
|
function split(s, sep) { return s.split(sep); }
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
20
app/templates/error.html
Normal file
20
app/templates/error.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{template "base.html" .}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<a href="/">Home</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="card max-w-lg mx-auto mt-12 text-center p-8 shadow-md">
|
||||||
|
<div class="mb-6 text-danger flex justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold mb-2">{{.Code}}</h1>
|
||||||
|
<p class="text-lg text-muted mb-8">{{.Message}}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<a class="btn btn-outline" href="/">Go Home</a>
|
||||||
|
<a class="btn btn-primary" href="javascript:history.back()">Try Again</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
61
app/templates/index.html
Normal file
61
app/templates/index.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{{template "base.html" .}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<a href="/" class="nav-link">Home</a>
|
||||||
|
<a href="https://github.com/smart-on-fhir" target="_blank" class="nav-link">About SMART</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<h1 class="text-4xl font-extrabold mb-4 text-primary">FHIR Health Platform</h1>
|
||||||
|
<p class="text-xl text-muted max-w-2xl mx-auto mb-8">
|
||||||
|
Secure, context-aware EHR integration powered by SMART on FHIR R4.
|
||||||
|
Seamlessly visualize patient data, sync records, and improve clinical workflows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<a class="btn btn-primary btn-lg px-6 py-3 text-lg"
|
||||||
|
href="https://launch.smarthealthit.org/?launch_url=http%3A%2F%2Flocalhost%3A8080%2Flaunch&iss=https%3A%2F%2Flaunch.smarthealthit.org%2Fv%2Fr4%2Ffhir"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
Launch Demo Environment
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-secondary" href="https://hl7.org/fhir/smart-app-launch/" target="_blank">
|
||||||
|
Read Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mt-12">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold mb-2">Secure Authentication</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
OAuth2 + OIDC integration ensures secure practitioner and patient context launch directly from the EHR.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold mb-2">Real-time Vitals</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
Instant synchronization of Observations, Conditions, and Medications using FHIR R4 standards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-header border-none p-0 mb-2">
|
||||||
|
<h3 class="text-lg font-bold">Clinical Notes</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">
|
||||||
|
Access DocumentReferences and clinical notes with built-in viewer support for PDF and text content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
117
app/templates/patients.html
Normal file
117
app/templates/patients.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{{template "base.html" .}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/dashboard" class="nav-link">Dashboard</a>
|
||||||
|
<a href="/" class="nav-link">Home</a>
|
||||||
|
<form action="/logout" method="POST" class="flex items-center">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h1 class="page-title">Patient Directory</h1>
|
||||||
|
<p class="page-subtitle">Showing all patients synced from <strong class="text-main font-medium">{{.Session.EHRURL}}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel p-0 overflow-hidden">
|
||||||
|
<div class="directory-toolbar">
|
||||||
|
<div class="search-wrapper w-full max-w-[500px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="patientSearch"
|
||||||
|
class="input-base search-input"
|
||||||
|
placeholder="Search by name, MRN, or FHIR ID..."
|
||||||
|
onkeyup="filterPatients()">
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-neutral text-xs font-semibold uppercase tracking-wider px-3 py-1">
|
||||||
|
{{len .Patients}} Patients
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Patients}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Patient Name</th>
|
||||||
|
<th>MRN</th>
|
||||||
|
<th>DOB (Age)</th>
|
||||||
|
<th>Gender</th>
|
||||||
|
<th class="text-right" >Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Patients}}
|
||||||
|
<tr class="patient-row"
|
||||||
|
data-name="{{.FirstName}} {{.LastName}}"
|
||||||
|
data-fhir-id="{{.FHIRID}}"
|
||||||
|
data-mrn="{{.MRN}}">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar avatar-sm">
|
||||||
|
{{if .FirstName}}{{slice .FirstName 0 1}}{{end}}{{if .LastName}}{{slice .LastName 0 1}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold text-main">{{.LastName}}, {{.FirstName}} {{.MiddleName}}</span>
|
||||||
|
<span class="text-xs text-muted">FHIR ID: {{.FHIRID}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="mrn-badge">{{orDash .MRN}}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span>{{formatDate .DOB}}</span>
|
||||||
|
<span class="text-xs text-muted">{{calculateAge .DOB}} years old</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{titleCase .Gender}}</td>
|
||||||
|
<td class="text-right align-middle">
|
||||||
|
<a href="/dashboard?patient_id={{.FHIRID}}" class="btn btn-primary btn-sm btn-icon">
|
||||||
|
View Chart
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state flex flex-col items-center justify-center py-16">
|
||||||
|
<div class="text-faint mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-main mb-2">No Patients Found</h3>
|
||||||
|
<p class="text-sm text-muted">Try syncing a patient from the EHR or check your connection.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
function filterPatients() {
|
||||||
|
var input = document.getElementById("patientSearch");
|
||||||
|
var filter = input.value.toLowerCase();
|
||||||
|
var rows = document.getElementsByClassName("patient-row");
|
||||||
|
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var name = rows[i].getAttribute("data-name").toLowerCase();
|
||||||
|
var fhirId = rows[i].getAttribute("data-fhir-id").toLowerCase();
|
||||||
|
var mrn = (rows[i].getAttribute("data-mrn") || "").toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(filter) || fhirId.includes(filter) || mrn.includes(filter)) {
|
||||||
|
rows[i].style.display = "";
|
||||||
|
} else {
|
||||||
|
rows[i].style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
14
go.mod
14
go.mod
@@ -1,3 +1,17 @@
|
|||||||
module github.com/AmanTahiliani/FHIR-Sandbox
|
module github.com/AmanTahiliani/FHIR-Sandbox
|
||||||
|
|
||||||
go 1.24.0
|
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
|
||||||
|
)
|
||||||
|
|||||||
23
go.sum
Normal file
23
go.sum
Normal file
@@ -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=
|
||||||
Reference in New Issue
Block a user