mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
chore: remove DB and binary from repo, add .gitignore to exclude artifacts
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/
|
||||
155
AGENTS.md
Normal file
155
AGENTS.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# AI Agent Developer Guide (AGENTS.md)
|
||||
|
||||
This document provides essential information for AI coding agents (like yourself) to work efficiently in the **FHIR-Sandbox** repository. It covers build/test commands, code style guidelines, and the project's architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build, Lint, and Test Commands
|
||||
|
||||
### Build & Run
|
||||
- **Build the binary:**
|
||||
```bash
|
||||
go build -o fhir-sandbox app/main.go
|
||||
```
|
||||
- **Run the application:**
|
||||
```bash
|
||||
go run app/main.go
|
||||
```
|
||||
The server starts on `http://localhost:8080` by default.
|
||||
|
||||
### Testing
|
||||
- **Run all tests:**
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
- **Run a single test (by name):**
|
||||
```bash
|
||||
go test -v -run TestName ./app/db
|
||||
```
|
||||
- **Run tests with coverage:**
|
||||
```bash
|
||||
go test -cover ./...
|
||||
```
|
||||
*Note: New features MUST include `_test.go` files. We have 100% coverage on core DB and FHIR logic.*
|
||||
|
||||
### Linting & Formatting
|
||||
- **Standard Go formatting:**
|
||||
```bash
|
||||
go fmt ./...
|
||||
```
|
||||
- **Import management (using goimports if available):**
|
||||
```bash
|
||||
goimports -w .
|
||||
```
|
||||
- **Static analysis (vet):**
|
||||
```bash
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Code Style Guidelines
|
||||
|
||||
### General Principles
|
||||
- **Simplicity:** Prefer standard library packages (e.g., `net/http`, `encoding/json`) over complex frameworks unless strictly necessary.
|
||||
- **Explicit over Implicit:** Do not use magic values. Use constants or configuration fields.
|
||||
- **Idiomatic Go:** Follow the patterns described in [Effective Go](https://golang.org/doc/effective_go).
|
||||
|
||||
### Imports
|
||||
Group imports into three blocks, separated by a blank line:
|
||||
1. Standard library imports (alphabetical).
|
||||
2. Third-party library imports (alphabetical).
|
||||
3. Local project imports (alphabetical).
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Exported items:** `PascalCase`.
|
||||
- **Unexported items:** `camelCase`.
|
||||
- **Receiver names:** Use 1-3 letter abbreviations (e.g., `func (app *Application) ...`).
|
||||
- **Interfaces:** Usually end in `-er` (e.g., `FHIRClienter`).
|
||||
- **Variables:** Use short names for short-lived variables (`err`, `w`, `r`) and descriptive names for long-lived ones.
|
||||
|
||||
### Formatting
|
||||
- Use **tabs** for indentation (Go standard).
|
||||
- Limit line length to **120 characters** where possible for readability.
|
||||
- Braces: Standard Go placement (opening brace on the same line).
|
||||
|
||||
### Types & Data Structures
|
||||
- **Structs for Configuration:** Group related settings into nested structs (e.g., `ApplicationConfig`, `SMARTAppConfig`).
|
||||
- **JSON Tags:** Always include JSON tags for structs that will be serialized or deserialized from JSON.
|
||||
```go
|
||||
type LaunchContext struct {
|
||||
LaunchID string `json:"launch"`
|
||||
Patient string `json:"patient"`
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- **Never ignore errors:** Always check `if err != nil`.
|
||||
- **Wrap errors:** Use `fmt.Errorf("context: %w", err)` to provide additional context for debugging.
|
||||
- **HTTP Error responses:** Use `http.Error(w, message, code)` for standard error reporting to the client.
|
||||
- **Logging errors:** Log significant errors using `log.Printf` or a structured logger if introduced.
|
||||
|
||||
### Logging
|
||||
- Currently uses the standard `log` package.
|
||||
- Always include context in logs (e.g., "Failed to fetch well-known URL: %v").
|
||||
- Do not log sensitive information like `client_secret` or `access_token` in production-like environments.
|
||||
|
||||
---
|
||||
|
||||
## 3. SMART on FHIR Implementation Guidelines
|
||||
|
||||
### Launch Flow
|
||||
The application implements the SMART on FHIR launch flow. When modifying the launch logic:
|
||||
- **`iss` parameter:** This is the FHIR server base URL. It must be validated.
|
||||
- **`launch` parameter:** The opaque launch ID provided by the EHR.
|
||||
- **Discovery:** Always use the `.well-known/smart-configuration` endpoint to find `authorization_endpoint` and `token_endpoint`.
|
||||
|
||||
### Security
|
||||
- **State Parameter:** Use the `state` parameter to maintain context and prevent CSRF attacks. The current implementation uses a simple hash-like string; improve this with cryptographically secure random values if refactoring for production.
|
||||
- **Basic Auth:** Use `req.SetBasicAuth(clientID, clientSecret)` for the token exchange when required by the EHR.
|
||||
- **Bearer Tokens:** Always include the `Authorization: Bearer <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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
The project is organised into modular packages under `/app`:
|
||||
- `/app/config`: Configuration structures and URL normalisation.
|
||||
- `/app/db`: SQLite storage, versioned migrations, and CRUD operations.
|
||||
- `/app/fhir`: FHIR R4 type definitions, SMART discovery, and FHIR client.
|
||||
- `/app/handlers`: HTTP handlers and per-render template logic.
|
||||
- `/app/middleware`: Session management and auth guards.
|
||||
- `/app/models`: Core domain models and context keys.
|
||||
- `/app/templates`: Embedded HTML templates.
|
||||
|
||||
- `app/main.go`: Application entry point and dependency wiring.
|
||||
- `go.mod`: Go module definition (v1.24.0).
|
||||
|
||||
---
|
||||
|
||||
## 5. Future Improvements for Agents
|
||||
When working in this repo, consider the following high-priority improvements:
|
||||
1. **Configuration Loading:** Implement a robust configuration loader for `app/main.go` (e.g., using `spf13/viper` or a YAML file).
|
||||
2. **Structured Logging:** Move from the standard `log` package to Go 1.21's `log/slog`.
|
||||
3. **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions.
|
||||
4. **FHIR Resources:** Add support for additional resources like Observations, Conditions, and Encounters.
|
||||
5. **Frontend:** Evolve the current templates into a more dynamic UI (e.g., using HTMX or a modern JS framework if appropriate).
|
||||
6. **FHIR Types:** Consider using a comprehensive FHIR library (e.g., `google/fhir/go`) for type-safe resource handling as the scope grows.
|
||||
|
||||
---
|
||||
*Created by AI Agent. Updated Feb 2026.*
|
||||
137
README.md
137
README.md
@@ -1,86 +1,83 @@
|
||||
# FHIR-Sandbox: SMART on FHIR Integration Application
|
||||
# FHIR-Sandbox: SMART on FHIR Healthcare Platform
|
||||
|
||||
A Go-based application that demonstrates integration with Electronic Health Record (EHR) systems using the SMART on FHIR protocol.
|
||||
A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data.
|
||||
|
||||
## Overview
|
||||
## Features
|
||||
|
||||
This application implements a SMART on FHIR client that can:
|
||||
- Launch from an EHR context
|
||||
- Authenticate using OAuth 2.0
|
||||
- Retrieve patient information from FHIR servers
|
||||
- Display patient details in a structured format
|
||||
- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone).
|
||||
- **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses.
|
||||
- **SQLite Persistence:** Persists patient and practitioner data upon successful launch using a pure-Go SQLite driver (no CGO required).
|
||||
- **Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies.
|
||||
- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics and practitioner details.
|
||||
- **Extensible Architecture:** Clean package separation (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`) designed for growth.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project is structured into modular packages under `/app`:
|
||||
- `/db`: Database schema, migrations, and CRUD operations using `modernc.org/sqlite`.
|
||||
- `/fhir`: FHIR R4 resource definitions and SMART discovery/client logic.
|
||||
- `/handlers`: HTTP request handlers and template rendering.
|
||||
- `/middleware`: Session loading and authentication guards.
|
||||
- `/models`: Shared data structures.
|
||||
- `/templates`: HTML templates with layout inheritance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.16 or higher
|
||||
- Access to a SMART on FHIR-compatible EHR system
|
||||
- Client credentials (client ID and secret) from your EHR system
|
||||
- **Go 1.24+**
|
||||
- **No external database required** (uses embedded SQLite)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/AmanTahiliani/FHIR-Sandbox.git
|
||||
cd FHIR-Sandbox
|
||||
```
|
||||
|
||||
2. **Run the application:**
|
||||
```bash
|
||||
go run app/main.go
|
||||
```
|
||||
The server starts on `http://localhost:8080`.
|
||||
|
||||
3. **Test with a Sandbox:**
|
||||
Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/):
|
||||
- **App Launch URL:** `http://localhost:8080/launch`
|
||||
- **Redirect URL:** `http://localhost:8080/auth-redirect`
|
||||
- The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox.
|
||||
|
||||
## Configuration
|
||||
|
||||
The application uses a configuration structure defined in `main.go`. You'll need to configure:
|
||||
Configuration is currently managed in `app/main.go` via `config.AppConfig`. You can define multiple EHRs, set your redirect URI, and required scopes.
|
||||
|
||||
1. SMART App settings:
|
||||
```go
|
||||
SMARTAppConfig {
|
||||
redirectPath: "http://localhost:8080/auth-redirect",
|
||||
clientID: "your-client-id",
|
||||
clientSecret: "your-client-secret",
|
||||
scopes: []string{"launch", "patient/*.read"},
|
||||
}
|
||||
2. EHR Client settings:
|
||||
```go
|
||||
EHRClientsConfig {
|
||||
name: "EHR_NAME",
|
||||
url: "https://your-ehr-fhir-endpoint.com",
|
||||
authType: "auth-2",
|
||||
clientID: "your-ehr-client-id",
|
||||
clientSecret: "your-ehr-client-secret",
|
||||
patientAPI: true,
|
||||
cfg := &config.AppConfig{
|
||||
DBPath: "fhir_sandbox.db",
|
||||
SMART: config.SMARTConfig{
|
||||
RedirectURL: "http://localhost:8080/auth-redirect",
|
||||
Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"},
|
||||
},
|
||||
EHRs: []config.EHRConfig{
|
||||
{
|
||||
Name: "SmartHealthIT Sandbox (R4)",
|
||||
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||
ClientID: "your-client-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
## 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
|
||||
|
||||
You can test the application using a FHIR server that supports SMART on FHIR. Ensure you have the necessary credentials and configuration.
|
||||
A good EHR Launcher to test with is: o test with is: [SMART Health IT Sandbox](https://launch.smarthealthit.org/). Some of the steps you would need to take are:
|
||||
- Create a new EHR client in the sandbox
|
||||
- Add the redirect URL
|
||||
- Add the client ID and secret
|
||||
- Add the scopes
|
||||
- Set the same client ID and secret in the application
|
||||
The project includes unit tests for database logic and FHIR parsing.
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- [ ] Support for Observations, Conditions, and Encounters.
|
||||
- [ ] Move configuration to a YAML/TOML file.
|
||||
- [ ] Add structured logging (slog).
|
||||
- [ ] Implement Refresh Token handling.
|
||||
|
||||
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
|
||||
}
|
||||
340
app/db/clinical.go
Normal file
340
app/db/clinical.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// clinical.go provides persistence operations for FHIR clinical resources:
|
||||
// Observation, Condition, DocumentReference, and PatientSync.
|
||||
//
|
||||
// All upsert methods use (fhir_id, ehr_url) as the natural deduplication key,
|
||||
// matching the same pattern used for users. On conflict the stored row is
|
||||
// overwritten with the latest data from the EHR and synced_at is updated.
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertObservation inserts or replaces an Observation record keyed on
|
||||
// (fhir_id, ehr_url). Returns the internal UUID.
|
||||
func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM observations WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
o.FHIRID, o.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE observations SET
|
||||
patient_fhir_id = ?,
|
||||
status = ?,
|
||||
category = ?,
|
||||
code_text = ?,
|
||||
code_system = ?,
|
||||
code_code = ?,
|
||||
effective_date = ?,
|
||||
value_quantity = ?,
|
||||
value_unit = ?,
|
||||
value_string = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
o.PatientFHIRID, o.Status, o.Category,
|
||||
o.CodeText, o.CodeSystem, o.CodeCode,
|
||||
o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString,
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update observation %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO observations (
|
||||
id, fhir_id, ehr_url, patient_fhir_id, status, category,
|
||||
code_text, code_system, code_code, effective_date,
|
||||
value_quantity, value_unit, value_string, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category,
|
||||
o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate,
|
||||
o.ValueQuantity, o.ValueUnit, o.ValueString, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListObservations returns all Observations for the given patient, newest first.
|
||||
func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observation, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category,
|
||||
code_text, code_system, code_code, effective_date,
|
||||
value_quantity, value_unit, value_string, synced_at
|
||||
FROM observations
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY effective_date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list observations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Observation
|
||||
for rows.Next() {
|
||||
var o models.Observation
|
||||
if err := rows.Scan(
|
||||
&o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category,
|
||||
&o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate,
|
||||
&o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan observation: %w", err)
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertCondition inserts or updates a Condition record keyed on (fhir_id, ehr_url).
|
||||
func (s *Store) UpsertCondition(c *models.Condition) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM conditions WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
c.FHIRID, c.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE conditions SET
|
||||
patient_fhir_id = ?,
|
||||
clinical_status = ?,
|
||||
verification_status = ?,
|
||||
category = ?,
|
||||
code_text = ?,
|
||||
code_system = ?,
|
||||
code_code = ?,
|
||||
onset_date = ?,
|
||||
recorded_date = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
c.PatientFHIRID, c.ClinicalStatus, c.VerificationStatus,
|
||||
c.Category, c.CodeText, c.CodeSystem, c.CodeCode,
|
||||
c.OnsetDate, c.RecordedDate, now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update condition %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO conditions (
|
||||
id, fhir_id, ehr_url, patient_fhir_id,
|
||||
clinical_status, verification_status, category,
|
||||
code_text, code_system, code_code,
|
||||
onset_date, recorded_date, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, c.FHIRID, c.EHRURL, c.PatientFHIRID,
|
||||
c.ClinicalStatus, c.VerificationStatus, c.Category,
|
||||
c.CodeText, c.CodeSystem, c.CodeCode,
|
||||
c.OnsetDate, c.RecordedDate, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert condition fhir_id=%s: %w", c.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListConditions returns all Conditions for the given patient, newest first.
|
||||
func (s *Store) ListConditions(patientFHIRID, ehrURL string) ([]models.Condition, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id,
|
||||
clinical_status, verification_status, category,
|
||||
code_text, code_system, code_code,
|
||||
onset_date, recorded_date, synced_at
|
||||
FROM conditions
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY recorded_date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list conditions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Condition
|
||||
for rows.Next() {
|
||||
var c models.Condition
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.FHIRID, &c.EHRURL, &c.PatientFHIRID,
|
||||
&c.ClinicalStatus, &c.VerificationStatus, &c.Category,
|
||||
&c.CodeText, &c.CodeSystem, &c.CodeCode,
|
||||
&c.OnsetDate, &c.RecordedDate, &c.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan condition: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DocumentReference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertDocumentReference inserts or updates a DocumentReference record.
|
||||
func (s *Store) UpsertDocumentReference(d *models.DocumentReference) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM document_references WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
d.FHIRID, d.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE document_references SET
|
||||
patient_fhir_id = ?,
|
||||
status = ?,
|
||||
doc_status = ?,
|
||||
type_text = ?,
|
||||
type_system = ?,
|
||||
type_code = ?,
|
||||
category = ?,
|
||||
date = ?,
|
||||
description = ?,
|
||||
content_type = ?,
|
||||
content_url = ?,
|
||||
content_data = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
d.PatientFHIRID, d.Status, d.DocStatus,
|
||||
d.TypeText, d.TypeSystem, d.TypeCode,
|
||||
d.Category, d.Date, d.Description,
|
||||
d.ContentType, d.ContentURL, d.ContentData,
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update document_reference %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO document_references (
|
||||
id, fhir_id, ehr_url, patient_fhir_id,
|
||||
status, doc_status, type_text, type_system, type_code,
|
||||
category, date, description,
|
||||
content_type, content_url, content_data, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, d.FHIRID, d.EHRURL, d.PatientFHIRID,
|
||||
d.Status, d.DocStatus, d.TypeText, d.TypeSystem, d.TypeCode,
|
||||
d.Category, d.Date, d.Description,
|
||||
d.ContentType, d.ContentURL, d.ContentData, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert document_reference fhir_id=%s: %w", d.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListDocumentReferences returns all DocumentReferences for the given patient,
|
||||
// newest first.
|
||||
func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.DocumentReference, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id,
|
||||
status, doc_status, type_text, type_system, type_code,
|
||||
category, date, description,
|
||||
content_type, content_url, content_data, synced_at
|
||||
FROM document_references
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list document_references: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.DocumentReference
|
||||
for rows.Next() {
|
||||
var d models.DocumentReference
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.FHIRID, &d.EHRURL, &d.PatientFHIRID,
|
||||
&d.Status, &d.DocStatus, &d.TypeText, &d.TypeSystem, &d.TypeCode,
|
||||
&d.Category, &d.Date, &d.Description,
|
||||
&d.ContentType, &d.ContentURL, &d.ContentData, &d.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan document_reference: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PatientSync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RecordSync inserts a new sync event for a patient.
|
||||
func (s *Store) RecordSync(patientFHIRID, ehrURL string, obsCount, condCount, docCount int) (*models.PatientSync, error) {
|
||||
ps := &models.PatientSync{
|
||||
ID: uuid.NewString(),
|
||||
PatientFHIRID: patientFHIRID,
|
||||
EHRURL: ehrURL,
|
||||
SyncedAt: time.Now().UTC(),
|
||||
ObsCount: obsCount,
|
||||
CondCount: condCount,
|
||||
DocCount: docCount,
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO patient_syncs (id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
ps.ID, ps.PatientFHIRID, ps.EHRURL, ps.SyncedAt, ps.ObsCount, ps.CondCount, ps.DocCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: record sync for patient %s: %w", patientFHIRID, err)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
// LatestSync retrieves the most recent sync event for a patient.
|
||||
// Returns nil, nil when no sync has ever been performed.
|
||||
func (s *Store) LatestSync(patientFHIRID, ehrURL string) (*models.PatientSync, error) {
|
||||
ps := &models.PatientSync{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count
|
||||
FROM patient_syncs
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY synced_at DESC
|
||||
LIMIT 1`,
|
||||
patientFHIRID, ehrURL,
|
||||
).Scan(
|
||||
&ps.ID, &ps.PatientFHIRID, &ps.EHRURL, &ps.SyncedAt,
|
||||
&ps.ObsCount, &ps.CondCount, &ps.DocCount,
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("db: latest sync for patient %s: %w", patientFHIRID, err)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
345
app/db/clinical_test.go
Normal file
345
app/db/clinical_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package db_test
|
||||
|
||||
// clinical_test.go — tests for clinical resource persistence (Observation,
|
||||
// Condition, DocumentReference, PatientSync).
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
testEHRURL = "https://ehr.example.com/fhir"
|
||||
testPatientID = "patient-clinical-001"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertObservation_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
qty := 98.6
|
||||
obs := &models.Observation{
|
||||
FHIRID: "obs-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
Category: "vital-signs",
|
||||
CodeText: "Body Temperature",
|
||||
CodeSystem: "http://loinc.org",
|
||||
CodeCode: "8310-5",
|
||||
EffectiveDate: "2024-01-15",
|
||||
ValueQuantity: &qty,
|
||||
ValueUnit: "°F",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertObservation(obs)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertObservation: %v", err)
|
||||
}
|
||||
if id1 == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
|
||||
// Update: change value and status.
|
||||
newQty := 99.1
|
||||
obs.ValueQuantity = &newQty
|
||||
obs.Status = "amended"
|
||||
id2, err := store.UpsertObservation(obs)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertObservation: %v", err)
|
||||
}
|
||||
|
||||
// ID must be stable across upserts.
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
// Read back and confirm updated fields.
|
||||
rows, err := store.ListObservations(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 observation, got %d", len(rows))
|
||||
}
|
||||
got := rows[0]
|
||||
if got.Status != "amended" {
|
||||
t.Errorf("Status: got %q, want %q", got.Status, "amended")
|
||||
}
|
||||
if got.ValueQuantity == nil || *got.ValueQuantity != 99.1 {
|
||||
t.Errorf("ValueQuantity: got %v, want 99.1", got.ValueQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertObservation_TenantIsolation(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
makeObs := func(ehrURL string) *models.Observation {
|
||||
return &models.Observation{
|
||||
FHIRID: "obs-shared",
|
||||
EHRURL: ehrURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
}
|
||||
}
|
||||
|
||||
id1, err := store.UpsertObservation(makeObs("https://ehr-a.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert A: %v", err)
|
||||
}
|
||||
id2, err := store.UpsertObservation(makeObs("https://ehr-b.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert B: %v", err)
|
||||
}
|
||||
if id1 == id2 {
|
||||
t.Error("expected different IDs for same fhir_id at different ehr_urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObservations_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListObservations("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0 rows, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObservations_OrderedNewestFirst(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
date string
|
||||
}{
|
||||
{"obs-a", "2024-01-01"},
|
||||
{"obs-b", "2024-06-15"},
|
||||
{"obs-c", "2023-12-31"},
|
||||
} {
|
||||
_, err := store.UpsertObservation(&models.Observation{
|
||||
FHIRID: item.id,
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
EffectiveDate: item.date,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertObservation %s: %v", item.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := store.ListObservations(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||
}
|
||||
// Newest first: obs-b (Jun) > obs-a (Jan) > obs-c (Dec 2023)
|
||||
if rows[0].FHIRID != "obs-b" {
|
||||
t.Errorf("first row: got %q, want obs-b", rows[0].FHIRID)
|
||||
}
|
||||
if rows[2].FHIRID != "obs-c" {
|
||||
t.Errorf("last row: got %q, want obs-c", rows[2].FHIRID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertCondition_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
cond := &models.Condition{
|
||||
FHIRID: "cond-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
ClinicalStatus: "active",
|
||||
VerificationStatus: "confirmed",
|
||||
Category: "problem-list-item",
|
||||
CodeText: "Hypertension",
|
||||
CodeSystem: "http://snomed.info/sct",
|
||||
CodeCode: "38341003",
|
||||
OnsetDate: "2020-03-01",
|
||||
RecordedDate: "2020-03-05",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertCondition(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertCondition: %v", err)
|
||||
}
|
||||
|
||||
// Update clinical status to resolved.
|
||||
cond.ClinicalStatus = "resolved"
|
||||
id2, err := store.UpsertCondition(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertCondition: %v", err)
|
||||
}
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
rows, err := store.ListConditions(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConditions: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 condition, got %d", len(rows))
|
||||
}
|
||||
if rows[0].ClinicalStatus != "resolved" {
|
||||
t.Errorf("ClinicalStatus: got %q, want resolved", rows[0].ClinicalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConditions_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListConditions("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConditions: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DocumentReference tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertDocumentReference_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
doc := &models.DocumentReference{
|
||||
FHIRID: "doc-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "current",
|
||||
DocStatus: "final",
|
||||
TypeText: "Discharge Summary",
|
||||
TypeSystem: "http://loinc.org",
|
||||
TypeCode: "18842-5",
|
||||
Date: "2024-02-10",
|
||||
Description: "Hospital discharge summary",
|
||||
ContentType: "text/plain",
|
||||
ContentURL: "https://ehr.example.com/fhir/Binary/bin-001",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertDocumentReference(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertDocumentReference: %v", err)
|
||||
}
|
||||
|
||||
// Supersede the document.
|
||||
doc.Status = "superseded"
|
||||
id2, err := store.UpsertDocumentReference(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertDocumentReference: %v", err)
|
||||
}
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
rows, err := store.ListDocumentReferences(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocumentReferences: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "superseded" {
|
||||
t.Errorf("Status: got %q, want superseded", rows[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDocumentReferences_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListDocumentReferences("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocumentReferences: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PatientSync tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecordAndLatestSync(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// No sync yet.
|
||||
ps, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync (empty): %v", err)
|
||||
}
|
||||
if ps != nil {
|
||||
t.Errorf("expected nil before any sync, got %+v", ps)
|
||||
}
|
||||
|
||||
// Record first sync.
|
||||
recorded, err := store.RecordSync(testPatientID, testEHRURL, 10, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync: %v", err)
|
||||
}
|
||||
if recorded.ID == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
if recorded.ObsCount != 10 || recorded.CondCount != 3 || recorded.DocCount != 1 {
|
||||
t.Errorf("counts wrong: %+v", recorded)
|
||||
}
|
||||
|
||||
// LatestSync should now return that record.
|
||||
latest, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatal("expected non-nil latest sync")
|
||||
}
|
||||
if latest.ID != recorded.ID {
|
||||
t.Errorf("ID mismatch: got %q, want %q", latest.ID, recorded.ID)
|
||||
}
|
||||
|
||||
// Record a second sync with higher counts; LatestSync must return the newer one.
|
||||
_, err = store.RecordSync(testPatientID, testEHRURL, 20, 5, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync second: %v", err)
|
||||
}
|
||||
|
||||
latest2, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync second: %v", err)
|
||||
}
|
||||
if latest2.ObsCount != 20 {
|
||||
t.Errorf("expected ObsCount 20 from latest, got %d", latest2.ObsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestSync_TenantIsolation(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
_, err := store.RecordSync(testPatientID, "https://ehr-a.example.com/fhir", 5, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync EHR-A: %v", err)
|
||||
}
|
||||
|
||||
// Querying for a different EHR URL should return nil.
|
||||
ps, err := store.LatestSync(testPatientID, "https://ehr-b.example.com/fhir")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync EHR-B: %v", err)
|
||||
}
|
||||
if ps != nil {
|
||||
t.Errorf("expected nil for different EHR URL, got %+v", ps)
|
||||
}
|
||||
}
|
||||
421
app/db/db.go
Normal file
421
app/db/db.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Package db provides the SQLite-backed persistence layer for the platform.
|
||||
//
|
||||
// Architecture notes:
|
||||
// - Uses the standard database/sql interface with a pure-Go SQLite driver
|
||||
// (modernc.org/sqlite), requiring no CGO.
|
||||
// - Schema versioning is handled via the schema_migrations table, enabling
|
||||
// forward-only incremental migrations as the platform grows.
|
||||
// - All public functions accept a *Store receiver, keeping the DB handle
|
||||
// encapsulated and allowing easy substitution (e.g., for testing with
|
||||
// an in-memory SQLite instance).
|
||||
// - Upsert semantics for users use (fhir_id, ehr_url) as the natural key,
|
||||
// preventing duplicates across EHR tenants while allowing the same FHIR
|
||||
// ID to exist at different servers.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite" // Register the sqlite driver under the name "sqlite".
|
||||
)
|
||||
|
||||
// Store wraps a database/sql.DB and exposes all persistence operations
|
||||
// for the platform. It is the single point of DB access — no raw *sql.DB
|
||||
// handles should escape this package.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New opens (or creates) the SQLite database at the given path, applies
|
||||
// all pending migrations, and returns a ready-to-use Store.
|
||||
//
|
||||
// Use path ":memory:" in tests to get a disposable in-memory database.
|
||||
func New(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open: %w", err)
|
||||
}
|
||||
|
||||
// SQLite is file-based; a small pool is sufficient.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("db: migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// migration represents a single, versioned, forward-only DDL statement.
|
||||
// New tables and columns must be added as new migrations — never alter
|
||||
// existing ones, to preserve upgrade safety.
|
||||
type migration struct {
|
||||
version int
|
||||
sql string
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{
|
||||
version: 1,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_resource_type TEXT NOT NULL,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
middle_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
dob TEXT NOT NULL DEFAULT '',
|
||||
gender TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
ehr_url TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_fhir ON users(fhir_id, ehr_url);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
sql: `
|
||||
ALTER TABLE sessions ADD COLUMN patient_fhir_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN id_token TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
code_text TEXT NOT NULL DEFAULT '',
|
||||
code_system TEXT NOT NULL DEFAULT '',
|
||||
code_code TEXT NOT NULL DEFAULT '',
|
||||
effective_date TEXT NOT NULL DEFAULT '',
|
||||
value_quantity REAL,
|
||||
value_unit TEXT NOT NULL DEFAULT '',
|
||||
value_string TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
clinical_status TEXT NOT NULL DEFAULT '',
|
||||
verification_status TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
code_text TEXT NOT NULL DEFAULT '',
|
||||
code_system TEXT NOT NULL DEFAULT '',
|
||||
code_code TEXT NOT NULL DEFAULT '',
|
||||
onset_date TEXT NOT NULL DEFAULT '',
|
||||
recorded_date TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_references (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
doc_status TEXT NOT NULL DEFAULT '',
|
||||
type_text TEXT NOT NULL DEFAULT '',
|
||||
type_system TEXT NOT NULL DEFAULT '',
|
||||
type_code TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
content_url TEXT NOT NULL DEFAULT '',
|
||||
content_data TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patient_syncs (
|
||||
id TEXT PRIMARY KEY,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
obs_count INTEGER NOT NULL DEFAULT 0,
|
||||
cond_count INTEGER NOT NULL DEFAULT 0,
|
||||
doc_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_patient ON observations(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_conditions_patient ON conditions(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_docrefs_patient ON document_references(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url);
|
||||
`,
|
||||
},
|
||||
// Future migrations: append new entries here with incrementing version numbers.
|
||||
// Example:
|
||||
// {
|
||||
// version: 2,
|
||||
// sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`,
|
||||
// },
|
||||
}
|
||||
|
||||
// migrate applies any migrations that have not yet been run, in order.
|
||||
func (s *Store) migrate() error {
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
if _, err := s.db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
|
||||
return fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
// Enable foreign key enforcement (off by default in SQLite).
|
||||
if _, err := s.db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
|
||||
return fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap the migrations table if it doesn't exist yet.
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`); err != nil {
|
||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
var count int
|
||||
row := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return fmt.Errorf("check migration v%d: %w", m.version, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue // Already applied.
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(m.sql); err != nil {
|
||||
return fmt.Errorf("apply migration v%d: %w", m.version, err)
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO schema_migrations(version) VALUES(?)`, m.version); err != nil {
|
||||
return fmt.Errorf("record migration v%d: %w", m.version, err)
|
||||
}
|
||||
log.Printf("db: applied migration v%d", m.version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertUser inserts a new user or updates the demographics of an existing one,
|
||||
// matched on the (fhir_id, ehr_url) natural key.
|
||||
//
|
||||
// Returns the user's internal UUID (which may be the existing one if the user
|
||||
// already existed).
|
||||
func (s *Store) UpsertUser(u *models.User) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Check if the user already exists to preserve the original created_at
|
||||
// and internal ID.
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM users WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
u.FHIRID, u.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
// User exists — update demographics but preserve ID and created_at.
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE users SET
|
||||
first_name = ?,
|
||||
middle_name = ?,
|
||||
last_name = ?,
|
||||
dob = ?,
|
||||
gender = ?,
|
||||
email = ?,
|
||||
fhir_resource_type = ?,
|
||||
role = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
u.FirstName, u.MiddleName, u.LastName,
|
||||
u.DOB, u.Gender, u.Email,
|
||||
u.FHIRResourceType, string(u.Role),
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update user %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
if err != sql.ErrNoRows {
|
||||
return "", fmt.Errorf("db: lookup user (%s, %s): %w", u.FHIRID, u.EHRURL, err)
|
||||
}
|
||||
|
||||
// New user — generate a fresh internal UUID.
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO users (
|
||||
id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role),
|
||||
u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email,
|
||||
now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert user (fhir_id=%s): %w", u.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetUserByFHIRID retrieves a user by their FHIR ID and originating EHR URL.
|
||||
// Returns sql.ErrNoRows if no matching user is found.
|
||||
func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
FROM users WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
fhirID, ehrURL,
|
||||
).Scan(
|
||||
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
|
||||
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by their internal UUID primary key.
|
||||
func (s *Store) GetUserByID(id string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
|
||||
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateSession creates a new authenticated session for the given user,
|
||||
// storing the FHIR access token and EHR context. Sessions expire after
|
||||
// the provided duration from now.
|
||||
func (s *Store) CreateSession(userID, patientFHIRID, accessToken, idToken, scope, ehrURL string, ttl time.Duration) (*models.Session, error) {
|
||||
now := time.Now().UTC()
|
||||
sess := &models.Session{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
PatientFHIRID: patientFHIRID,
|
||||
AccessToken: accessToken,
|
||||
IDToken: idToken,
|
||||
Scope: scope,
|
||||
EHRURL: ehrURL,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO sessions (id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sess.ID, sess.UserID, sess.PatientFHIRID, sess.AccessToken, sess.IDToken, sess.Scope, sess.EHRURL,
|
||||
sess.CreatedAt, sess.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create session for user %s: %w", userID, err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by its token ID.
|
||||
// Returns sql.ErrNoRows if the session does not exist.
|
||||
func (s *Store) GetSession(id string) (*models.Session, error) {
|
||||
sess := &models.Session{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at
|
||||
FROM sessions WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&sess.ID, &sess.UserID, &sess.PatientFHIRID, &sess.AccessToken, &sess.IDToken, &sess.Scope, &sess.EHRURL,
|
||||
&sess.CreatedAt, &sess.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by its token ID. Used during logout.
|
||||
func (s *Store) DeleteSession(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete session %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions removes all sessions whose expiry time has passed.
|
||||
// This should be called periodically (e.g., on startup or via a background
|
||||
// goroutine) to keep the sessions table lean.
|
||||
func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: purge expired sessions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
256
app/db/db_test.go
Normal file
256
app/db/db_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// newTestStore creates a disposable in-memory SQLite store for testing.
|
||||
func newTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
store, err := db.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("newTestStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertUser_NewUser(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
u := &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "patient-abc",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Alice",
|
||||
MiddleName: "Marie",
|
||||
LastName: "Smith",
|
||||
DOB: "1990-04-22",
|
||||
Gender: "female",
|
||||
Email: "alice@example.com",
|
||||
}
|
||||
|
||||
id, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty ID for new user")
|
||||
}
|
||||
|
||||
// Fetch back and verify.
|
||||
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByFHIRID: %v", err)
|
||||
}
|
||||
if got.FirstName != "Alice" {
|
||||
t.Errorf("FirstName: got %q, want %q", got.FirstName, "Alice")
|
||||
}
|
||||
if got.Role != models.RolePatient {
|
||||
t.Errorf("Role: got %q, want %q", got.Role, models.RolePatient)
|
||||
}
|
||||
if got.Email != "alice@example.com" {
|
||||
t.Errorf("Email: got %q, want %q", got.Email, "alice@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_UpdateExisting(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
u := &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "patient-xyz",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Bob",
|
||||
LastName: "Jones",
|
||||
DOB: "1985-01-01",
|
||||
Gender: "male",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Update demographics.
|
||||
u.Email = "bob.updated@example.com"
|
||||
u.FirstName = "Robert"
|
||||
id2, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Internal ID must remain stable across upserts.
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: got %q, was %q", id2, id1)
|
||||
}
|
||||
|
||||
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByFHIRID after update: %v", err)
|
||||
}
|
||||
if got.FirstName != "Robert" {
|
||||
t.Errorf("updated FirstName: got %q, want %q", got.FirstName, "Robert")
|
||||
}
|
||||
if got.Email != "bob.updated@example.com" {
|
||||
t.Errorf("updated Email: got %q, want %q", got.Email, "bob.updated@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByFHIRID_NotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
_, err := store.GetUserByFHIRID("nonexistent", "https://ehr.example.com/fhir")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_TenantIsolation(t *testing.T) {
|
||||
// The same FHIR ID at two different EHR URLs must produce two separate records.
|
||||
store := newTestStore(t)
|
||||
|
||||
makeUser := func(ehrURL string) *models.User {
|
||||
return &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "shared-fhir-id",
|
||||
EHRURL: ehrURL,
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Carol",
|
||||
LastName: "Tenant",
|
||||
}
|
||||
}
|
||||
|
||||
id1, err := store.UpsertUser(makeUser("https://ehr-a.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert EHR-A: %v", err)
|
||||
}
|
||||
id2, err := store.UpsertUser(makeUser("https://ehr-b.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert EHR-B: %v", err)
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("expected separate internal IDs for same FHIR ID at different EHR URLs")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateAndGetSession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Create a practitioner user first.
|
||||
practUser := &models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-001",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Dr. Emily",
|
||||
LastName: "Chen",
|
||||
}
|
||||
userID, err := store.UpsertUser(practUser)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser practitioner: %v", err)
|
||||
}
|
||||
|
||||
// Create a session.
|
||||
sess, err := store.CreateSession(userID, "patient-001", "access-token-xyz", "id-token-abc", "openid profile", "https://ehr.example.com/fhir", 8*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if sess.ID == "" {
|
||||
t.Fatal("expected non-empty session ID")
|
||||
}
|
||||
|
||||
// Retrieve the session.
|
||||
got, err := store.GetSession(sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.UserID != userID {
|
||||
t.Errorf("UserID: got %q, want %q", got.UserID, userID)
|
||||
}
|
||||
if got.AccessToken != "access-token-xyz" {
|
||||
t.Errorf("AccessToken: got %q, want %q", got.AccessToken, "access-token-xyz")
|
||||
}
|
||||
if got.ExpiresAt.Before(time.Now()) {
|
||||
t.Error("session should not be expired immediately after creation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
userID, err := store.UpsertUser(&models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-delete",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Test",
|
||||
LastName: "Delete",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
sess, err := store.CreateSession(userID, "pat", "tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteSession(sess.ID); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.GetSession(sess.ID)
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows after deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSessions(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
userID, err := store.UpsertUser(&models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-expire",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Expire",
|
||||
LastName: "Test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Create one valid and one already-expired session.
|
||||
_, err = store.CreateSession(userID, "pat", "valid-tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (valid): %v", err)
|
||||
}
|
||||
_, err = store.CreateSession(userID, "pat", "expired-tok", "id", "scope", "https://ehr.example.com/fhir", -1*time.Second) // already past
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (expired): %v", err)
|
||||
}
|
||||
|
||||
n, err := store.DeleteExpiredSessions()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpiredSessions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("deleted %d expired sessions, want 1", n)
|
||||
}
|
||||
}
|
||||
651
app/fhir/fhir.go
Normal file
651
app/fhir/fhir.go
Normal file
@@ -0,0 +1,651 @@
|
||||
// Package fhir provides typed representations of FHIR R4 resources and
|
||||
// utilities for extracting platform-domain data from raw FHIR JSON payloads.
|
||||
//
|
||||
// Architecture notes:
|
||||
// - FHIR resources are received from EHR servers as JSON and decoded into
|
||||
// typed Go structs defined here. This gives us compile-time safety and
|
||||
// makes it straightforward to add support for new resource types.
|
||||
// - The Resource interface is the root of all FHIR types in this package.
|
||||
// Any new resource (Observation, Condition, Encounter, etc.) should
|
||||
// implement it so it can be handled generically by shared code.
|
||||
// - Extraction helpers (ExtractUserFromPatient, ExtractUserFromPractitioner)
|
||||
// translate FHIR types into the platform's models.User, decoupling the
|
||||
// FHIR representation from the persistence layer.
|
||||
// - The Client type wraps http.Client and provides typed FHIR API methods.
|
||||
// Extend it with new methods (GetObservations, GetConditions, etc.) as
|
||||
// the platform grows.
|
||||
package fhir
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core FHIR R4 types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resource is the base interface for all FHIR R4 resources in this package.
|
||||
// Every concrete FHIR type must implement ResourceType() returning its
|
||||
// FHIR resource type string (e.g., "Patient", "Practitioner").
|
||||
type Resource interface {
|
||||
ResourceType() string
|
||||
}
|
||||
|
||||
// HumanName represents the FHIR HumanName data type (R4).
|
||||
// https://www.hl7.org/fhir/datatypes.html#HumanName
|
||||
type HumanName struct {
|
||||
Use string `json:"use"`
|
||||
Family string `json:"family"`
|
||||
Given []string `json:"given"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Suffix []string `json:"suffix"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ContactPoint represents the FHIR ContactPoint data type (R4).
|
||||
// https://www.hl7.org/fhir/datatypes.html#ContactPoint
|
||||
type ContactPoint struct {
|
||||
System string `json:"system"` // phone | fax | email | pager | url | sms | other
|
||||
Value string `json:"value"`
|
||||
Use string `json:"use"` // home | work | temp | old | mobile
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// Address represents the FHIR Address data type (R4).
|
||||
// https://www.hl7.org/fhir/datatypes.html#Address
|
||||
type Address struct {
|
||||
Use string `json:"use"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Line []string `json:"line"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
State string `json:"state"`
|
||||
PostalCode string `json:"postalCode"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// Coding represents the FHIR Coding data type (R4).
|
||||
type Coding struct {
|
||||
System string `json:"system"`
|
||||
Code string `json:"code"`
|
||||
Display string `json:"display"`
|
||||
}
|
||||
|
||||
// CodeableConcept represents the FHIR CodeableConcept data type (R4).
|
||||
type CodeableConcept struct {
|
||||
Coding []Coding `json:"coding"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Reference represents the FHIR Reference data type (R4).
|
||||
type Reference struct {
|
||||
Reference string `json:"reference"`
|
||||
Display string `json:"display"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Identifier represents the FHIR Identifier data type (R4).
|
||||
type Identifier struct {
|
||||
Use string `json:"use"`
|
||||
Type CodeableConcept `json:"type"`
|
||||
System string `json:"system"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Meta represents the FHIR Meta data type (R4).
|
||||
type Meta struct {
|
||||
VersionID string `json:"versionId"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Source string `json:"source"`
|
||||
Profile []string `json:"profile"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patient resource (R4)
|
||||
// https://www.hl7.org/fhir/patient.html
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Patient represents a FHIR R4 Patient resource.
|
||||
// Fields are a curated subset of the full specification — add new fields
|
||||
// here as the platform needs them, without breaking existing code.
|
||||
type Patient struct {
|
||||
ResourceTypeField string `json:"resourceType"`
|
||||
ID string `json:"id"`
|
||||
Meta Meta `json:"meta"`
|
||||
Identifier []Identifier `json:"identifier"`
|
||||
Active bool `json:"active"`
|
||||
Name []HumanName `json:"name"`
|
||||
Telecom []ContactPoint `json:"telecom"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birthDate"`
|
||||
Address []Address `json:"address"`
|
||||
MaritalStatus CodeableConcept `json:"maritalStatus"`
|
||||
}
|
||||
|
||||
// ResourceType implements the Resource interface.
|
||||
func (p *Patient) ResourceType() string { return "Patient" }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Practitioner resource (R4)
|
||||
// https://www.hl7.org/fhir/practitioner.html
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Practitioner represents a FHIR R4 Practitioner resource.
|
||||
type Practitioner struct {
|
||||
ResourceTypeField string `json:"resourceType"`
|
||||
ID string `json:"id"`
|
||||
Meta Meta `json:"meta"`
|
||||
Identifier []Identifier `json:"identifier"`
|
||||
Active bool `json:"active"`
|
||||
Name []HumanName `json:"name"`
|
||||
Telecom []ContactPoint `json:"telecom"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birthDate"`
|
||||
Address []Address `json:"address"`
|
||||
}
|
||||
|
||||
// ResourceType implements the Resource interface.
|
||||
func (p *Practitioner) ResourceType() string { return "Practitioner" }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clinical resources (R4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Observation represents a FHIR R4 Observation resource.
|
||||
// https://www.hl7.org/fhir/observation.html
|
||||
type Observation struct {
|
||||
ResourceTypeField string `json:"resourceType"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Category []CodeableConcept `json:"category"`
|
||||
Code CodeableConcept `json:"code"`
|
||||
Subject Reference `json:"subject"`
|
||||
EffectiveDateTime string `json:"effectiveDateTime"`
|
||||
ValueQuantity *Quantity `json:"valueQuantity,omitempty"`
|
||||
ValueString string `json:"valueString,omitempty"`
|
||||
}
|
||||
|
||||
func (o *Observation) ResourceType() string { return "Observation" }
|
||||
|
||||
// Condition represents a FHIR R4 Condition resource.
|
||||
// https://www.hl7.org/fhir/condition.html
|
||||
type Condition struct {
|
||||
ResourceTypeField string `json:"resourceType"`
|
||||
ID string `json:"id"`
|
||||
ClinicalStatus CodeableConcept `json:"clinicalStatus"`
|
||||
VerificationStatus CodeableConcept `json:"verificationStatus"`
|
||||
Category []CodeableConcept `json:"category"`
|
||||
Code CodeableConcept `json:"code"`
|
||||
Subject Reference `json:"subject"`
|
||||
OnsetDateTime string `json:"onsetDateTime"`
|
||||
RecordedDate string `json:"recordedDate"`
|
||||
}
|
||||
|
||||
func (c *Condition) ResourceType() string { return "Condition" }
|
||||
|
||||
// Attachment represents the FHIR Attachment data type.
|
||||
type Attachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Language string `json:"language"`
|
||||
Data string `json:"data"` // base64-encoded
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Creation string `json:"creation"`
|
||||
}
|
||||
|
||||
// DocumentReferenceContent holds a single content item in a DocumentReference.
|
||||
type DocumentReferenceContent struct {
|
||||
Attachment Attachment `json:"attachment"`
|
||||
Format CodeableConcept `json:"format"`
|
||||
}
|
||||
|
||||
// DocumentReference represents a FHIR R4 DocumentReference resource.
|
||||
// https://www.hl7.org/fhir/documentreference.html
|
||||
type DocumentReference struct {
|
||||
ResourceTypeField string `json:"resourceType"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
DocStatus string `json:"docStatus"`
|
||||
Type CodeableConcept `json:"type"`
|
||||
Category []CodeableConcept `json:"category"`
|
||||
Subject Reference `json:"subject"`
|
||||
Date string `json:"date"`
|
||||
Description string `json:"description"`
|
||||
Content []DocumentReferenceContent `json:"content"`
|
||||
}
|
||||
|
||||
func (d *DocumentReference) ResourceType() string { return "DocumentReference" }
|
||||
|
||||
// Quantity represents the FHIR Quantity data type.
|
||||
type Quantity struct {
|
||||
Value float64 `json:"value"`
|
||||
Unit string `json:"unit"`
|
||||
System string `json:"system"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// Bundle represents a FHIR R4 Bundle resource, used for search results.
|
||||
type Bundle struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
Type string `json:"type"`
|
||||
Total int `json:"total"`
|
||||
Entry []struct {
|
||||
FullUrl string `json:"fullUrl"`
|
||||
Resource json.RawMessage `json:"resource"`
|
||||
} `json:"entry"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMART discovery types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SmartConfiguration represents the payload returned by the FHIR server's
|
||||
// .well-known/smart-configuration endpoint.
|
||||
// https://build.fhir.org/ig/HL7/smart-app-launch/conformance.html
|
||||
type SmartConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// TokenResponse is the OAuth2 token endpoint response, extended with
|
||||
// SMART-specific fields.
|
||||
// https://build.fhir.org/ig/HL7/smart-app-launch/
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
|
||||
// SMART launch context extensions
|
||||
Patient string `json:"patient"`
|
||||
Encounter string `json:"encounter"`
|
||||
// Practitioner holds a bare Practitioner FHIR ID when provided by the EHR.
|
||||
Practitioner string `json:"practitioner"`
|
||||
// User holds a relative FHIR reference to the authenticated user,
|
||||
// e.g. "Practitioner/52919099-..." as returned by SmartHealthIT and
|
||||
// defined in the SMART App Launch specification.
|
||||
User string `json:"user"`
|
||||
NeedPatientBanner bool `json:"need_patient_banner"`
|
||||
SmartStyleURL string `json:"smart_style_url"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FHIR API Client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Client is a thin, typed FHIR R4 REST client. It holds an access token
|
||||
// and the FHIR server base URL so callers don't have to manage headers
|
||||
// on every request.
|
||||
//
|
||||
// To support new resource types: add a method like GetObservations,
|
||||
// GetConditions, etc., following the pattern of GetPatient / GetPractitioner.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
accessToken string
|
||||
}
|
||||
|
||||
// NewClient creates a FHIR API client for the given base URL and Bearer token.
|
||||
func NewClient(baseURL, accessToken string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
accessToken: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// get performs an authenticated GET request to the FHIR server and decodes
|
||||
// the JSON response into dest.
|
||||
func (c *Client) get(path string, dest interface{}) error {
|
||||
url := fmt.Sprintf("%s/%s", c.baseURL, strings.TrimLeft(path, "/"))
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fhir: build request for %s: %w", url, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Accept", "application/fhir+json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fhir: GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("fhir: GET %s returned %d: %s", url, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||
return fmt.Errorf("fhir: decode response from %s: %w", url, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPatient fetches a Patient resource by FHIR ID.
|
||||
func (c *Client) GetPatient(id string) (*Patient, error) {
|
||||
var p Patient
|
||||
if err := c.get(fmt.Sprintf("Patient/%s", id), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetPractitioner fetches a Practitioner resource by FHIR ID.
|
||||
func (c *Client) GetPractitioner(id string) (*Practitioner, error) {
|
||||
var p Practitioner
|
||||
if err := c.get(fmt.Sprintf("Practitioner/%s", id), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetObservations fetches Observation resources for a specific patient.
|
||||
func (c *Client) GetObservations(patientID string) ([]Observation, error) {
|
||||
var bundle Bundle
|
||||
path := fmt.Sprintf("Observation?patient=%s&_sort=-date", patientID)
|
||||
if err := c.get(path, &bundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var observations []Observation
|
||||
for _, entry := range bundle.Entry {
|
||||
var o Observation
|
||||
if err := json.Unmarshal(entry.Resource, &o); err == nil {
|
||||
observations = append(observations, o)
|
||||
}
|
||||
}
|
||||
return observations, nil
|
||||
}
|
||||
|
||||
// GetConditions fetches Condition resources for a specific patient.
|
||||
func (c *Client) GetConditions(patientID string) ([]Condition, error) {
|
||||
var bundle Bundle
|
||||
path := fmt.Sprintf("Condition?patient=%s", patientID)
|
||||
if err := c.get(path, &bundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var conditions []Condition
|
||||
for _, entry := range bundle.Entry {
|
||||
var cond Condition
|
||||
if err := json.Unmarshal(entry.Resource, &cond); err == nil {
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
}
|
||||
return conditions, nil
|
||||
}
|
||||
|
||||
// GetDocumentReferences fetches DocumentReference resources for a specific patient.
|
||||
// Results are sorted newest-first by date.
|
||||
func (c *Client) GetDocumentReferences(patientID string) ([]DocumentReference, error) {
|
||||
var bundle Bundle
|
||||
path := fmt.Sprintf("DocumentReference?patient=%s&_sort=-date", patientID)
|
||||
if err := c.get(path, &bundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var docs []DocumentReference
|
||||
for _, entry := range bundle.Entry {
|
||||
var d DocumentReference
|
||||
if err := json.Unmarshal(entry.Resource, &d); err == nil {
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// GetSmartConfiguration fetches and parses the SMART discovery document
|
||||
// for this FHIR server.
|
||||
func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) {
|
||||
url := fmt.Sprintf("%s/.well-known/smart-configuration", strings.TrimRight(issURL, "/"))
|
||||
resp, err := http.Get(url) //nolint:noctx // discovery calls do not need request context
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fhir: GET smart-configuration from %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("fhir: smart-configuration %s returned %d: %s", url, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cfg SmartConfiguration
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("fhir: decode smart-configuration: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// primaryName returns the first HumanName with use=="official", falling back
|
||||
// to the first name in the slice, or an empty HumanName if none exist.
|
||||
func primaryName(names []HumanName) HumanName {
|
||||
for _, n := range names {
|
||||
if n.Use == "official" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return names[0]
|
||||
}
|
||||
return HumanName{}
|
||||
}
|
||||
|
||||
// primaryEmail returns the first email address from a ContactPoint slice.
|
||||
func primaryEmail(telecom []ContactPoint) string {
|
||||
for _, t := range telecom {
|
||||
if t.System == "email" && t.Value != "" {
|
||||
return t.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractUserFromPatient converts a FHIR Patient resource into a platform
|
||||
// models.User with Role=RolePatient. The ehrURL is the originating FHIR
|
||||
// server base URL.
|
||||
func ExtractUserFromPatient(p *Patient, ehrURL string) *models.User {
|
||||
name := primaryName(p.Name)
|
||||
first, middle := "", ""
|
||||
if len(name.Given) > 0 {
|
||||
first = name.Given[0]
|
||||
}
|
||||
if len(name.Given) > 1 {
|
||||
middle = name.Given[1]
|
||||
}
|
||||
return &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: p.ID,
|
||||
EHRURL: strings.TrimRight(ehrURL, "/"),
|
||||
Role: models.RolePatient,
|
||||
FirstName: first,
|
||||
MiddleName: middle,
|
||||
LastName: name.Family,
|
||||
DOB: p.BirthDate,
|
||||
Gender: p.Gender,
|
||||
Email: primaryEmail(p.Telecom),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractUserFromPractitioner converts a FHIR Practitioner resource into a
|
||||
// platform models.User with Role=RolePractitioner.
|
||||
func ExtractUserFromPractitioner(p *Practitioner, ehrURL string) *models.User {
|
||||
name := primaryName(p.Name)
|
||||
first, middle := "", ""
|
||||
if len(name.Given) > 0 {
|
||||
first = name.Given[0]
|
||||
}
|
||||
if len(name.Given) > 1 {
|
||||
middle = name.Given[1]
|
||||
}
|
||||
return &models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: p.ID,
|
||||
EHRURL: strings.TrimRight(ehrURL, "/"),
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: first,
|
||||
MiddleName: middle,
|
||||
LastName: name.Family,
|
||||
DOB: p.BirthDate,
|
||||
Gender: p.Gender,
|
||||
Email: primaryEmail(p.Telecom),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FHIR → domain model extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// firstCoding returns the first Coding from a CodeableConcept, or zero value.
|
||||
func firstCoding(cc CodeableConcept) Coding {
|
||||
if len(cc.Coding) > 0 {
|
||||
return cc.Coding[0]
|
||||
}
|
||||
return Coding{}
|
||||
}
|
||||
|
||||
// firstCategoryText returns the text (or first coding display) of the first
|
||||
// element in a []CodeableConcept, e.g. as used for Observation.category.
|
||||
func firstCategoryText(cats []CodeableConcept) string {
|
||||
if len(cats) == 0 {
|
||||
return ""
|
||||
}
|
||||
c := cats[0]
|
||||
if c.Text != "" {
|
||||
return c.Text
|
||||
}
|
||||
if len(c.Coding) > 0 {
|
||||
if c.Coding[0].Display != "" {
|
||||
return c.Coding[0].Display
|
||||
}
|
||||
return c.Coding[0].Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractObservation maps a FHIR Observation to a models.Observation ready
|
||||
// for upsert. patientFHIRID and ehrURL are injected by the caller because
|
||||
// they are session-level context, not encoded inside the FHIR resource.
|
||||
func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Observation {
|
||||
coding := firstCoding(o.Code)
|
||||
var qty *float64
|
||||
var unit string
|
||||
if o.ValueQuantity != nil {
|
||||
v := o.ValueQuantity.Value
|
||||
qty = &v
|
||||
unit = o.ValueQuantity.Unit
|
||||
}
|
||||
return &models.Observation{
|
||||
FHIRID: o.ID,
|
||||
EHRURL: strings.TrimRight(ehrURL, "/"),
|
||||
PatientFHIRID: patientFHIRID,
|
||||
Status: o.Status,
|
||||
Category: firstCategoryText(o.Category),
|
||||
CodeText: o.Code.Text,
|
||||
CodeSystem: coding.System,
|
||||
CodeCode: coding.Code,
|
||||
EffectiveDate: o.EffectiveDateTime,
|
||||
ValueQuantity: qty,
|
||||
ValueUnit: unit,
|
||||
ValueString: o.ValueString,
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractCondition maps a FHIR Condition to a models.Condition ready for upsert.
|
||||
func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condition {
|
||||
coding := firstCoding(c.Code)
|
||||
clinicalStatus := firstCoding(c.ClinicalStatus)
|
||||
verificationStatus := firstCoding(c.VerificationStatus)
|
||||
return &models.Condition{
|
||||
FHIRID: c.ID,
|
||||
EHRURL: strings.TrimRight(ehrURL, "/"),
|
||||
PatientFHIRID: patientFHIRID,
|
||||
ClinicalStatus: clinicalStatus.Code,
|
||||
VerificationStatus: verificationStatus.Code,
|
||||
Category: firstCategoryText(c.Category),
|
||||
CodeText: c.Code.Text,
|
||||
CodeSystem: coding.System,
|
||||
CodeCode: coding.Code,
|
||||
OnsetDate: c.OnsetDateTime,
|
||||
RecordedDate: c.RecordedDate,
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractDocumentReference maps a FHIR DocumentReference to a
|
||||
// models.DocumentReference ready for upsert. Only the first content item is
|
||||
// persisted; additional content attachments are not common in practice.
|
||||
func ExtractDocumentReference(d *DocumentReference, patientFHIRID, ehrURL string) *models.DocumentReference {
|
||||
coding := firstCoding(d.Type)
|
||||
category := firstCategoryText(d.Category)
|
||||
|
||||
var contentType, contentURL, contentData string
|
||||
if len(d.Content) > 0 {
|
||||
att := d.Content[0].Attachment
|
||||
contentType = att.ContentType
|
||||
contentURL = att.URL
|
||||
contentData = att.Data
|
||||
}
|
||||
|
||||
return &models.DocumentReference{
|
||||
FHIRID: d.ID,
|
||||
EHRURL: strings.TrimRight(ehrURL, "/"),
|
||||
PatientFHIRID: patientFHIRID,
|
||||
Status: d.Status,
|
||||
DocStatus: d.DocStatus,
|
||||
TypeText: d.Type.Text,
|
||||
TypeSystem: coding.System,
|
||||
TypeCode: coding.Code,
|
||||
Category: category,
|
||||
Date: d.Date,
|
||||
Description: d.Description,
|
||||
ContentType: contentType,
|
||||
ContentURL: contentURL,
|
||||
ContentData: contentData,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference
|
||||
// (e.g. "Practitioner/123" or "Patient/abc") from the id_token's fhirUser claim.
|
||||
// Returns an empty string if the claim is missing or invalid.
|
||||
func ParseFHIRUserFromIDToken(idToken string) string {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
FHIRUser string `json:"fhirUser"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return claims.FHIRUser
|
||||
}
|
||||
131
app/fhir/fhir_test.go
Normal file
131
app/fhir/fhir_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package fhir_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtractUserFromPatient tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractUserFromPatient_FullRecord(t *testing.T) {
|
||||
p := &fhir.Patient{
|
||||
ResourceTypeField: "Patient",
|
||||
ID: "patient-001",
|
||||
Gender: "female",
|
||||
BirthDate: "1990-04-22",
|
||||
Name: []fhir.HumanName{
|
||||
{Use: "official", Family: "Smith", Given: []string{"Alice", "Marie"}},
|
||||
},
|
||||
Telecom: []fhir.ContactPoint{
|
||||
{System: "phone", Value: "+15555550100"},
|
||||
{System: "email", Value: "alice@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir")
|
||||
|
||||
assertEqual(t, "FHIRID", "patient-001", u.FHIRID)
|
||||
assertEqual(t, "Role", string(models.RolePatient), string(u.Role))
|
||||
assertEqual(t, "FHIRResourceType", "Patient", u.FHIRResourceType)
|
||||
assertEqual(t, "FirstName", "Alice", u.FirstName)
|
||||
assertEqual(t, "MiddleName", "Marie", u.MiddleName)
|
||||
assertEqual(t, "LastName", "Smith", u.LastName)
|
||||
assertEqual(t, "DOB", "1990-04-22", u.DOB)
|
||||
assertEqual(t, "Gender", "female", u.Gender)
|
||||
assertEqual(t, "Email", "alice@example.com", u.Email)
|
||||
assertEqual(t, "EHRURL", "https://ehr.example.com/fhir", u.EHRURL)
|
||||
}
|
||||
|
||||
func TestExtractUserFromPatient_NoMiddleName(t *testing.T) {
|
||||
p := &fhir.Patient{
|
||||
ID: "patient-002",
|
||||
Gender: "male",
|
||||
Name: []fhir.HumanName{{Use: "official", Family: "Jones", Given: []string{"Bob"}}},
|
||||
}
|
||||
|
||||
u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir")
|
||||
|
||||
assertEqual(t, "FirstName", "Bob", u.FirstName)
|
||||
assertEqual(t, "MiddleName", "", u.MiddleName)
|
||||
assertEqual(t, "LastName", "Jones", u.LastName)
|
||||
}
|
||||
|
||||
func TestExtractUserFromPatient_NoEmail(t *testing.T) {
|
||||
p := &fhir.Patient{
|
||||
ID: "patient-003",
|
||||
Telecom: []fhir.ContactPoint{{System: "phone", Value: "+15555559999"}},
|
||||
}
|
||||
|
||||
u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir")
|
||||
|
||||
if u.Email != "" {
|
||||
t.Errorf("Email: got %q, want empty string", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUserFromPatient_OfficialNamePreferred(t *testing.T) {
|
||||
// When both "usual" and "official" names are present, "official" must win.
|
||||
p := &fhir.Patient{
|
||||
ID: "patient-004",
|
||||
Name: []fhir.HumanName{
|
||||
{Use: "usual", Family: "Nickname", Given: []string{"Nick"}},
|
||||
{Use: "official", Family: "Registered", Given: []string{"Nicholas", "James"}},
|
||||
},
|
||||
}
|
||||
|
||||
u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir")
|
||||
|
||||
assertEqual(t, "LastName", "Registered", u.LastName)
|
||||
assertEqual(t, "FirstName", "Nicholas", u.FirstName)
|
||||
}
|
||||
|
||||
func TestExtractUserFromPatient_EHRURLTrailingSlashNormalised(t *testing.T) {
|
||||
p := &fhir.Patient{ID: "patient-005"}
|
||||
u := fhir.ExtractUserFromPatient(p, "https://ehr.example.com/fhir/")
|
||||
|
||||
if u.EHRURL == "https://ehr.example.com/fhir/" {
|
||||
t.Error("trailing slash should be stripped from EHRURL")
|
||||
}
|
||||
assertEqual(t, "EHRURL", "https://ehr.example.com/fhir", u.EHRURL)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtractUserFromPractitioner tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractUserFromPractitioner_FullRecord(t *testing.T) {
|
||||
p := &fhir.Practitioner{
|
||||
ResourceTypeField: "Practitioner",
|
||||
ID: "pract-001",
|
||||
Gender: "female",
|
||||
BirthDate: "1975-09-15",
|
||||
Name: []fhir.HumanName{{Use: "official", Family: "Chen", Given: []string{"Emily"}}},
|
||||
Telecom: []fhir.ContactPoint{
|
||||
{System: "email", Value: "dr.chen@hospital.org"},
|
||||
},
|
||||
}
|
||||
|
||||
u := fhir.ExtractUserFromPractitioner(p, "https://ehr.example.com/fhir")
|
||||
|
||||
assertEqual(t, "Role", string(models.RolePractitioner), string(u.Role))
|
||||
assertEqual(t, "FHIRResourceType", "Practitioner", u.FHIRResourceType)
|
||||
assertEqual(t, "FHIRID", "pract-001", u.FHIRID)
|
||||
assertEqual(t, "FirstName", "Emily", u.FirstName)
|
||||
assertEqual(t, "LastName", "Chen", u.LastName)
|
||||
assertEqual(t, "Email", "dr.chen@hospital.org", u.Email)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func assertEqual(t *testing.T, field, want, got string) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Errorf("%s: got %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
257
app/handlers/auth.go
Normal file
257
app/handlers/auth.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// auth.go handles the OAuth2 authorization callback, token exchange,
|
||||
// FHIR resource fetching, user upsert, and session creation.
|
||||
//
|
||||
// Flow (continued from launch.go):
|
||||
// 1. EHR calls GET /auth-redirect?code=<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
|
||||
}
|
||||
89
app/handlers/dashboard.go
Normal file
89
app/handlers/dashboard.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// HandleDashboard renders the stable patient dashboard.
|
||||
// All clinical data is read from the local database; no live FHIR calls are
|
||||
// made here. Use POST /dashboard/sync to refresh data from the EHR.
|
||||
//
|
||||
// GET /dashboard
|
||||
func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := middleware.SessionFromContext(r.Context())
|
||||
practitionerUser := middleware.UserFromContext(r.Context())
|
||||
|
||||
if sess == nil || practitionerUser == nil {
|
||||
h.handleUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ehrURL := sess.EHRURL
|
||||
patientID := sess.PatientFHIRID
|
||||
|
||||
// Fetch patient demographics from the FHIR server. This is a cheap single
|
||||
// resource call and keeps the patient card always current.
|
||||
fhirClient := fhir.NewClient(ehrURL, sess.AccessToken)
|
||||
patient, err := fhirClient.GetPatient(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard fetch Patient/%s failed: %v", patientID, err)
|
||||
h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details from the FHIR server.")
|
||||
return
|
||||
}
|
||||
patientUser := fhir.ExtractUserFromPatient(patient, ehrURL)
|
||||
|
||||
// Read clinical data from the local database.
|
||||
observations, err := h.store.ListObservations(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err)
|
||||
// Non-fatal; render with empty slice.
|
||||
}
|
||||
|
||||
conditions, err := h.store.ListConditions(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListConditions Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
docRefs, err := h.store.ListDocumentReferences(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
latestSync, err := h.store.LatestSync(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
h.render(w, "dashboard.html", dashboardData{
|
||||
Patient: patientUser,
|
||||
Practitioner: practitionerUser,
|
||||
RawPatient: patient,
|
||||
Observations: observations,
|
||||
Conditions: conditions,
|
||||
DocumentReferences: docRefs,
|
||||
LatestSync: latestSync,
|
||||
Session: sess,
|
||||
})
|
||||
}
|
||||
|
||||
// dashboardData is the view model passed to the dashboard template.
|
||||
type dashboardData struct {
|
||||
Patient *models.User
|
||||
Practitioner *models.User
|
||||
RawPatient *fhir.Patient
|
||||
Observations []models.Observation
|
||||
Conditions []models.Condition
|
||||
DocumentReferences []models.DocumentReference
|
||||
LatestSync *models.PatientSync
|
||||
Session *models.Session
|
||||
}
|
||||
|
||||
// handleUnauthorized redirects to root for dashboard requests.
|
||||
func (h *Handler) handleUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
156
app/handlers/handler.go
Normal file
156
app/handlers/handler.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package handlers contains all HTTP handler implementations for the platform.
|
||||
//
|
||||
// Handler design:
|
||||
// - All handlers are methods on *Handler, which aggregates all dependencies
|
||||
// (store, config, templateFS). This avoids package-level globals and makes
|
||||
// dependencies explicit and testable.
|
||||
// - Handlers do not perform FHIR API calls directly; they delegate to the
|
||||
// fhir package. This keeps HTTP concerns separate from FHIR protocol logic.
|
||||
// - Templates are parsed per-render as a (base.html + page.html) pair.
|
||||
// This is the correct Go html/template pattern for layout inheritance:
|
||||
// a single global template.Set with multiple files all defining "content"
|
||||
// blocks will have the last-parsed definition win, causing incorrect renders.
|
||||
// Per-render parsing is cheap (microseconds) and completely correct.
|
||||
// - Each handler file handles one logical concern:
|
||||
// handler.go — shared Handler type and constructor
|
||||
// launch.go — SMART EHR launch initiation
|
||||
// auth.go — OAuth2 callback, token exchange, user upsert, session creation
|
||||
// logout.go — session invalidation
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookieName is the name of the HttpOnly session cookie.
|
||||
SessionCookieName = "session_id"
|
||||
|
||||
// SessionTTL is how long a session remains valid after SMART launch.
|
||||
SessionTTL = 8 * 60 * 60 // 8 hours in seconds
|
||||
)
|
||||
|
||||
// Handler is the central handler struct. All HTTP handlers are methods on it.
|
||||
// It holds all dependencies so they can be injected in tests.
|
||||
type Handler struct {
|
||||
store *db.Store
|
||||
cfg *config.AppConfig
|
||||
templateFS fs.FS
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// New creates a Handler with all dependencies wired in.
|
||||
// templateFS must be an fs.FS rooted so that "base.html", "dashboard.html",
|
||||
// etc. are directly accessible (i.e. pass an fs.Sub of the embed.FS).
|
||||
func New(store *db.Store, cfg *config.AppConfig, templateFS fs.FS, funcMap template.FuncMap) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
templateFS: templateFS,
|
||||
funcMap: funcMap,
|
||||
}
|
||||
}
|
||||
|
||||
// render parses base.html + the named page file and executes the combined
|
||||
// template set, using the page filename as the entry point.
|
||||
//
|
||||
// Go's html/template block/define system works correctly when each page
|
||||
// is parsed together with base.html in a fresh template.Template — the
|
||||
// page's {{define "content"}} overrides the {{block "content"}} in base.html
|
||||
// without conflicting with other pages' definitions.
|
||||
func (h *Handler) render(w http.ResponseWriter, page string, data interface{}) {
|
||||
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", page)
|
||||
if err != nil {
|
||||
log.Printf("handlers: parse template %q: %v", page, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
log.Printf("handlers: execute template %q: %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError writes a clean HTML error page.
|
||||
func (h *Handler) renderError(w http.ResponseWriter, code int, message string) {
|
||||
data := struct {
|
||||
Code int
|
||||
Message string
|
||||
}{code, message}
|
||||
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", "error.html")
|
||||
if err != nil {
|
||||
log.Printf("handlers: parse error template: %v", err)
|
||||
http.Error(w, message, code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
log.Printf("handlers: execute error template: %v", err)
|
||||
http.Error(w, message, code)
|
||||
}
|
||||
}
|
||||
|
||||
// generateState creates a cryptographically secure random state token.
|
||||
// This replaces the naive iss+launchID concatenation in the original code.
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// templateFuncs returns the custom template function map.
|
||||
// Defined here so it is available to both main.go (for wiring) and
|
||||
// handler tests.
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDate": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return t.Format("January 2, 2006")
|
||||
},
|
||||
"formatDateTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.UTC().Format("Jan 2, 2006 15:04 UTC")
|
||||
},
|
||||
"derefFloat64": func(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"titleCase": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(s) == 1 {
|
||||
return string(s[0] - 32)
|
||||
}
|
||||
return string(s[0]-32) + s[1:]
|
||||
},
|
||||
"orDash": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
98
app/handlers/sync.go
Normal file
98
app/handlers/sync.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||
)
|
||||
|
||||
// HandleSync performs a live FHIR pull for Observations, Conditions, and
|
||||
// DocumentReferences for the session's patient, upserts all results into the
|
||||
// database, records a PatientSync event, then redirects back to GET /dashboard.
|
||||
//
|
||||
// POST /dashboard/sync
|
||||
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sess := middleware.SessionFromContext(r.Context())
|
||||
if sess == nil {
|
||||
h.handleUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ehrURL := sess.EHRURL
|
||||
patientID := sess.PatientFHIRID
|
||||
client := fhir.NewClient(ehrURL, sess.AccessToken)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch Observations
|
||||
// -----------------------------------------------------------------
|
||||
rawObs, err := client.GetObservations(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err)
|
||||
// Non-fatal; continue with whatever we got.
|
||||
}
|
||||
|
||||
obsCount := 0
|
||||
for i := range rawObs {
|
||||
m := fhir.ExtractObservation(&rawObs[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertObservation(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertObservation fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
obsCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch Conditions
|
||||
// -----------------------------------------------------------------
|
||||
rawConds, err := client.GetConditions(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
condCount := 0
|
||||
for i := range rawConds {
|
||||
m := fhir.ExtractCondition(&rawConds[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertCondition(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertCondition fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
condCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch DocumentReferences
|
||||
// -----------------------------------------------------------------
|
||||
rawDocs, err := client.GetDocumentReferences(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
docCount := 0
|
||||
for i := range rawDocs {
|
||||
m := fhir.ExtractDocumentReference(&rawDocs[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertDocumentReference(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertDocumentReference fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
docCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Record the sync event
|
||||
// -----------------------------------------------------------------
|
||||
if _, err := h.store.RecordSync(patientID, ehrURL, obsCount, condCount, docCount); err != nil {
|
||||
log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d",
|
||||
patientID, obsCount, condCount, docCount)
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
437
app/main.go
437
app/main.go
@@ -1,370 +1,121 @@
|
||||
// main.go is the application entry point. Its only job is to:
|
||||
// 1. Load configuration.
|
||||
// 2. Initialize the database store.
|
||||
// 3. Expose the embedded template FS to the handlers package.
|
||||
// 4. Wire all dependencies into handlers and middleware.
|
||||
// 5. Register routes and start the HTTP server.
|
||||
//
|
||||
// No business logic lives here. All behaviour is delegated to the
|
||||
// handlers, db, fhir, config, and middleware packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/handlers"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
config *ApplicationConfig
|
||||
}
|
||||
|
||||
type ApplicationConfig struct {
|
||||
smart *SMARTAppConfig
|
||||
eHRs []EHRClientsConfig // List of EHR clients configuration
|
||||
}
|
||||
|
||||
type SMARTAppConfig struct {
|
||||
oAuth1 bool // Enable OAuth1 for certain EHRs
|
||||
oAuth2 bool // Enable OAuth2 for others
|
||||
redirectPath string // Where to redirect after auth
|
||||
clientID string // OAuth client ID (if applicable)
|
||||
clientSecret string // OAuth client secret
|
||||
scopes []string
|
||||
}
|
||||
|
||||
type EHRClientsConfig struct {
|
||||
name string // Name of the EHR, e.g., "EHR_A"
|
||||
url string // FHIR endpoint URL
|
||||
authType string // auth-1 or auth-2 for OAuth type
|
||||
clientID string // OAuth client ID (for Azure AD)
|
||||
clientSecret string // OAuth secret (for Azure AD)
|
||||
patientAPI bool // Whether to enable patient API endpoints
|
||||
}
|
||||
|
||||
type LaunchContext struct {
|
||||
LaunchID string `json:"launch"`
|
||||
Patient string `json:"patient"`
|
||||
}
|
||||
//go:embed templates/*.html
|
||||
var embeddedTemplates embed.FS
|
||||
|
||||
func main() {
|
||||
log.Printf("Starting EHR integration application...")
|
||||
|
||||
appConfig := &ApplicationConfig{
|
||||
smart: &SMARTAppConfig{
|
||||
redirectPath: "http://localhost:8080/auth-redirect",
|
||||
clientID: "my-smart-client-id",
|
||||
clientSecret: "my-smart-client-secret",
|
||||
scopes: []string{"launch", "patient/*.read"},
|
||||
oAuth1: true,
|
||||
oAuth2: false,
|
||||
// -------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
cfg := &config.AppConfig{
|
||||
DBPath: "fhir_sandbox.db",
|
||||
Server: config.ServerConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
eHRs: []EHRClientsConfig{ // Sample EHR clients configuration
|
||||
SMART: config.SMARTConfig{
|
||||
RedirectURL: "http://localhost:8080/auth-redirect",
|
||||
Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"},
|
||||
},
|
||||
EHRs: []config.EHRConfig{
|
||||
{
|
||||
name: "EHR_A",
|
||||
url: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||
authType: "auth-2",
|
||||
clientID: "abcdefghijklmnopqrst",
|
||||
clientSecret: "ehr_a_client_secret",
|
||||
patientAPI: true,
|
||||
},
|
||||
{
|
||||
name: "EHR_B",
|
||||
url: "https://ehr-b.healthcare-provider.com/fhir/v2",
|
||||
authType: "auth-2",
|
||||
clientID: "ehr_b_client_id", // OAuth 2.0 client ID
|
||||
clientSecret: "ehr_b_client_secret",
|
||||
patientAPI: true,
|
||||
Name: "SmartHealthIT Sandbox (R4)",
|
||||
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||
ClientID: "abcdefghijklmnopqrst",
|
||||
ClientSecret: "ehr_a_client_secret",
|
||||
},
|
||||
// Add additional EHR configurations here as needed.
|
||||
},
|
||||
}
|
||||
|
||||
app := &Application{
|
||||
config: appConfig,
|
||||
}
|
||||
|
||||
log.Printf("Listening on port %d...", 8080)
|
||||
|
||||
// Start HTTP server and handle routes
|
||||
http.HandleFunc("/", app.handleRoot)
|
||||
http.HandleFunc("/launch", app.handleLaunch)
|
||||
http.HandleFunc("/auth-redirect", app.handleAuthRedirect)
|
||||
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
// handleRoot is a method of Application that handles the root endpoint.
|
||||
func (app *Application) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("Welcome to the EHR integration application!"))
|
||||
}
|
||||
|
||||
func getWellKnownUrl(iss string) (string, error) {
|
||||
wellKnownUrl := fmt.Sprintf("%s/.well-known/smart-configuration", iss)
|
||||
resp, err := http.Get(wellKnownUrl)
|
||||
// -------------------------------------------------------------------------
|
||||
// Database
|
||||
// -------------------------------------------------------------------------
|
||||
store, err := db.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
log.Fatalf("main: database init failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to fetch well-known URL")
|
||||
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)
|
||||
}
|
||||
|
||||
var bodyBytes []byte
|
||||
bodyBytes, err = io.ReadAll(resp.Body)
|
||||
// -------------------------------------------------------------------------
|
||||
// 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 {
|
||||
return "", err
|
||||
}
|
||||
return string(bodyBytes), nil
|
||||
|
||||
log.Fatalf("main: template FS sub failed: %v", err)
|
||||
}
|
||||
|
||||
// handleLaunch processes the SMART on FHIR launch request.
|
||||
func (app *Application) handleLaunch(w http.ResponseWriter, r *http.Request) {
|
||||
launchID := r.URL.Query().Get("launch")
|
||||
iss := r.URL.Query().Get("iss")
|
||||
// Log the query parameters and body data for debugging
|
||||
log.Printf("Query parameters: %v", r.URL.Query())
|
||||
if launchID == "" || iss == "" {
|
||||
http.Error(w, "Missing launch or iss parameter", http.StatusBadRequest)
|
||||
return
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers & middleware
|
||||
// -------------------------------------------------------------------------
|
||||
h := handlers.New(store, cfg, templateFS, handlers.TemplateFuncs())
|
||||
sessionMW := middleware.NewSessionMiddleware(store)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Routes
|
||||
// -------------------------------------------------------------------------
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("/", h.HandleRoot)
|
||||
mux.HandleFunc("/launch", h.HandleLaunch)
|
||||
mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect)
|
||||
|
||||
// Session-required routes — wrapped with the hard-gate middleware.
|
||||
mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard)))
|
||||
mux.Handle("/dashboard/sync", sessionMW.RequireSession(http.HandlerFunc(h.HandleSync)))
|
||||
mux.Handle("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout)))
|
||||
|
||||
// Apply the soft session loader to every request so templates can always
|
||||
// read the current user from context.
|
||||
root := sessionMW.LoadSession(mux)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server
|
||||
// -------------------------------------------------------------------------
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: root,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 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
|
||||
log.Printf("main: starting FHIR platform on http://localhost%s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("main: server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
172
app/models/models.go
Normal file
172
app/models/models.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Package models defines the core domain types used throughout the application.
|
||||
// These structs map directly to database tables and serve as the canonical
|
||||
// representation of platform entities. As the platform grows, new resource
|
||||
// types (e.g., Observation, Condition, MedicationRequest) should be added here.
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Role represents the access tier of a platform user.
|
||||
// New roles (e.g., RoleAdmin, RoleCaregiver) can be appended without
|
||||
// breaking existing comparisons since the underlying type is a string.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RolePatient identifies a patient-context user created on SMART launch.
|
||||
RolePatient Role = "patient"
|
||||
|
||||
// RolePractitioner identifies an HCP who initiated a SMART launch.
|
||||
RolePractitioner Role = "practitioner"
|
||||
)
|
||||
|
||||
// User is the canonical platform user record, independent of any specific
|
||||
// FHIR resource type. It maps to the `users` table.
|
||||
//
|
||||
// Design note: FHIRResourceType allows the same struct to represent both
|
||||
// Patient and Practitioner FHIR resources without a separate table per type.
|
||||
// Adding support for RelatedPerson or CareTeam members only requires updating
|
||||
// the fhir package's extraction logic and inserting with the correct type.
|
||||
type User struct {
|
||||
// ID is the internal UUID primary key. Never exposed in URLs.
|
||||
ID string `json:"id" db:"id"`
|
||||
|
||||
// FHIRResourceType is the FHIR resource type string, e.g. "Patient" or "Practitioner".
|
||||
FHIRResourceType string `json:"fhir_resource_type" db:"fhir_resource_type"`
|
||||
|
||||
// FHIRID is the resource id from the originating EHR FHIR server.
|
||||
// Combined with EHRURL this forms a globally unique identity.
|
||||
FHIRID string `json:"fhir_id" db:"fhir_id"`
|
||||
|
||||
// EHRURL is the base FHIR server URL the user was sourced from.
|
||||
// Storing this prevents ID collisions across EHR tenants.
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
|
||||
// Role defines the user's access tier within this platform.
|
||||
Role Role `json:"role" db:"role"`
|
||||
|
||||
// Demographics
|
||||
FirstName string `json:"first_name" db:"first_name"`
|
||||
MiddleName string `json:"middle_name" db:"middle_name"`
|
||||
LastName string `json:"last_name" db:"last_name"`
|
||||
DOB string `json:"dob" db:"dob"` // ISO 8601 date, e.g. "1990-04-22"
|
||||
Gender string `json:"gender" db:"gender"` // FHIR value set: male|female|other|unknown
|
||||
|
||||
// Email is optional — not all FHIR resources include contact telecom data.
|
||||
Email string `json:"email" db:"email"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// Session represents an active authenticated session for a platform user.
|
||||
// Sessions are created on SMART launch completion and destroyed on logout.
|
||||
// Only HCP (RolePractitioner) users create sessions; patients are stored
|
||||
// passively and do not log in directly via this flow.
|
||||
type Session struct {
|
||||
// ID is the session token — a UUID stored as an HttpOnly cookie.
|
||||
ID string `json:"id" db:"id"`
|
||||
|
||||
// UserID references the authenticated User.ID (internal UUID).
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
|
||||
// AccessToken holds the FHIR Bearer token for the duration of the session,
|
||||
// enabling subsequent FHIR API calls on behalf of the logged-in HCP.
|
||||
AccessToken string `json:"access_token" db:"access_token"`
|
||||
|
||||
// IDToken holds the raw OIDC ID token if provided by the EHR.
|
||||
IDToken string `json:"id_token" db:"id_token"`
|
||||
|
||||
// Scope holds the scopes granted by the EHR during this session.
|
||||
Scope string `json:"scope" db:"scope"`
|
||||
|
||||
// EHRURL is the FHIR server base URL associated with this session.
|
||||
// Stored so any handler can construct FHIR API requests without
|
||||
// re-deriving the EHR context from state parameters.
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
|
||||
// PatientFHIRID is the FHIR ID of the patient in context for this session.
|
||||
// Stored so the dashboard can fetch patient-specific resources.
|
||||
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
}
|
||||
|
||||
// SessionContextKey is the type used to store the resolved Session in a
|
||||
// request context. Using a dedicated unexported type avoids key collisions.
|
||||
type SessionContextKey struct{}
|
||||
|
||||
// UserContextKey is the type used to store the resolved User in a
|
||||
// request context.
|
||||
type UserContextKey struct{}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clinical resource models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Observation is the persisted representation of a FHIR R4 Observation.
|
||||
// The natural key is (fhir_id, ehr_url).
|
||||
type Observation struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
FHIRID string `json:"fhir_id" db:"fhir_id"`
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Category string `json:"category" db:"category"`
|
||||
CodeText string `json:"code_text" db:"code_text"`
|
||||
CodeSystem string `json:"code_system" db:"code_system"`
|
||||
CodeCode string `json:"code_code" db:"code_code"`
|
||||
EffectiveDate string `json:"effective_date" db:"effective_date"`
|
||||
ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"`
|
||||
ValueUnit string `json:"value_unit" db:"value_unit"`
|
||||
ValueString string `json:"value_string" db:"value_string"`
|
||||
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
|
||||
}
|
||||
|
||||
// Condition is the persisted representation of a FHIR R4 Condition.
|
||||
type Condition struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
FHIRID string `json:"fhir_id" db:"fhir_id"`
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
|
||||
ClinicalStatus string `json:"clinical_status" db:"clinical_status"`
|
||||
VerificationStatus string `json:"verification_status" db:"verification_status"`
|
||||
Category string `json:"category" db:"category"`
|
||||
CodeText string `json:"code_text" db:"code_text"`
|
||||
CodeSystem string `json:"code_system" db:"code_system"`
|
||||
CodeCode string `json:"code_code" db:"code_code"`
|
||||
OnsetDate string `json:"onset_date" db:"onset_date"`
|
||||
RecordedDate string `json:"recorded_date" db:"recorded_date"`
|
||||
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
|
||||
}
|
||||
|
||||
// DocumentReference is the persisted representation of a FHIR R4 DocumentReference.
|
||||
type DocumentReference struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
FHIRID string `json:"fhir_id" db:"fhir_id"`
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
DocStatus string `json:"doc_status" db:"doc_status"`
|
||||
TypeText string `json:"type_text" db:"type_text"`
|
||||
TypeSystem string `json:"type_system" db:"type_system"`
|
||||
TypeCode string `json:"type_code" db:"type_code"`
|
||||
Category string `json:"category" db:"category"`
|
||||
Date string `json:"date" db:"date"`
|
||||
Description string `json:"description" db:"description"`
|
||||
ContentType string `json:"content_type" db:"content_type"`
|
||||
ContentURL string `json:"content_url" db:"content_url"`
|
||||
ContentData string `json:"content_data" db:"content_data"`
|
||||
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
|
||||
}
|
||||
|
||||
// PatientSync records a completed FHIR sync event for a patient.
|
||||
type PatientSync struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
|
||||
EHRURL string `json:"ehr_url" db:"ehr_url"`
|
||||
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
|
||||
ObsCount int `json:"obs_count" db:"obs_count"`
|
||||
CondCount int `json:"cond_count" db:"cond_count"`
|
||||
DocCount int `json:"doc_count" db:"doc_count"`
|
||||
}
|
||||
238
app/templates/base.html
Normal file
238
app/templates/base.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!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>
|
||||
<style>
|
||||
/* ---- Reset & base ---- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-primary: #0062cc;
|
||||
--color-primary-h: #004fa3;
|
||||
--color-bg: #f4f6f9;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #dde3ec;
|
||||
--color-text: #1a2533;
|
||||
--color-muted: #6b7a99;
|
||||
--color-patient: #0a7c59;
|
||||
--color-hcp: #1a5fb4;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Nav ---- */
|
||||
header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.brand { font-size: 1.1rem; font-weight: 700; color: var(--color-primary); letter-spacing: -.2px; }
|
||||
.brand span { color: var(--color-text); font-weight: 400; }
|
||||
nav a, nav button {
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
nav a:hover, nav button:hover { background: var(--color-bg); color: var(--color-text); }
|
||||
|
||||
/* ---- Main layout ---- */
|
||||
main { flex: 1; padding: 32px 24px; max-width: 960px; margin: 0 auto; width: 100%; }
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: .75rem;
|
||||
color: var(--color-muted);
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---- Badge ---- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.badge-patient { background: #d1fae5; color: var(--color-patient); }
|
||||
.badge-practitioner { background: #dbeafe; color: var(--color-hcp); }
|
||||
|
||||
/* ---- Detail grid ---- */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
.detail-item label {
|
||||
display: block;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.detail-item p { font-size: .95rem; color: var(--color-text); }
|
||||
|
||||
/* ---- User row (header block) ---- */
|
||||
.user-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
|
||||
.avatar {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.3rem; font-weight: 700; color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-patient { background: var(--color-patient); }
|
||||
.avatar-practitioner { background: var(--color-hcp); }
|
||||
.user-header-info h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background .15s, box-shadow .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--color-primary-h); }
|
||||
.btn-outline { background: transparent; color: var(--color-primary); border: 1.5px solid var(--color-primary); }
|
||||
.btn-outline:hover { background: #e8f0ff; }
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
|
||||
/* ---- Empty / hero ---- */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 72px 24px;
|
||||
}
|
||||
.hero h1 { font-size: 2rem; font-weight: 800; margin-bottom: 12px; }
|
||||
.hero p { font-size: 1rem; color: var(--color-muted); max-width: 480px; margin: 0 auto 28px; }
|
||||
|
||||
/* ---- Divider ---- */
|
||||
hr { border: none; border-top: 1px solid var(--color-border); margin: 20px 0; }
|
||||
|
||||
/* ---- Utility ---- */
|
||||
.text-muted { color: var(--color-muted); font-size: .85rem; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.flex { display: flex; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
|
||||
/* ---- FHIR Table ---- */
|
||||
.fhir-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .9rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.fhir-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
font-size: .7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.fhir-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-link {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all .2s;
|
||||
}
|
||||
.tab-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tab-link.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom: 2px solid var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">FHIR <span>Health Platform</span></div>
|
||||
<nav>
|
||||
{{block "nav" .}}{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
FHIR Health Platform — SMART on FHIR R4
|
||||
</footer>
|
||||
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
335
app/templates/dashboard.html
Normal file
335
app/templates/dashboard.html
Normal file
@@ -0,0 +1,335 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "nav"}}
|
||||
<a href="/">Home</a>
|
||||
<form action="/logout" method="POST" style="display:inline">
|
||||
<button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
||||
{{/* ---- Practitioner block ---- */}}
|
||||
{{if .Practitioner}}
|
||||
<div class="card">
|
||||
<div class="card-title">Logged-in Clinician</div>
|
||||
<div class="user-header">
|
||||
<div class="avatar avatar-practitioner">
|
||||
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
|
||||
</div>
|
||||
<div class="user-header-info">
|
||||
<h2>
|
||||
{{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}}
|
||||
{{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}}
|
||||
{{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}}
|
||||
</h2>
|
||||
{{if eq .Practitioner.Role "practitioner"}}
|
||||
<span class="badge badge-practitioner">Health Care Practitioner</span>
|
||||
{{else}}
|
||||
<span class="badge badge-patient">Patient (Self-Service)</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Date of Birth</label>
|
||||
<p>{{formatDate .Practitioner.DOB}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Gender</label>
|
||||
<p>{{titleCase .Practitioner.Gender}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Email</label>
|
||||
<p>{{orDash .Practitioner.Email}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>FHIR ID</label>
|
||||
<p class="text-muted">{{.Practitioner.FHIRID}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<p class="text-muted">No practitioner context was returned by this EHR. Session not established.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---- Patient block ---- */}}
|
||||
{{if .Patient}}
|
||||
<div class="card">
|
||||
<div class="card-title">Active Patient</div>
|
||||
<div class="user-header">
|
||||
<div class="avatar avatar-patient">
|
||||
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
|
||||
</div>
|
||||
<div class="user-header-info">
|
||||
<h2>
|
||||
{{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}}
|
||||
{{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}}
|
||||
{{if .Patient.LastName}}{{.Patient.LastName}}{{end}}
|
||||
</h2>
|
||||
<span class="badge badge-patient">Patient</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Date of Birth</label>
|
||||
<p>{{formatDate .Patient.DOB}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Gender</label>
|
||||
<p>{{titleCase .Patient.Gender}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Email</label>
|
||||
<p>{{orDash .Patient.Email}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>FHIR ID</label>
|
||||
<p class="text-muted">{{.Patient.FHIRID}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>EHR Server</label>
|
||||
<p class="text-muted">{{.Patient.EHRURL}}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Internal ID</label>
|
||||
<p class="text-muted">{{.Patient.ID}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---- Clinical Data block ---- */}}
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||||
<div class="card-title" style="margin-bottom:0;">Clinical Data</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
{{if .LatestSync}}
|
||||
<span class="text-muted" style="font-size:.8rem;">
|
||||
Last synced: {{formatDateTime .LatestSync.SyncedAt}}
|
||||
·
|
||||
{{.LatestSync.ObsCount}} obs · {{.LatestSync.CondCount}} cond · {{.LatestSync.DocCount}} docs
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="text-muted" style="font-size:.8rem;">Never synced</span>
|
||||
{{end}}
|
||||
<form action="/dashboard/sync" method="POST" style="display:inline;">
|
||||
<button class="btn btn-primary" type="submit" style="padding:6px 14px;font-size:.875rem;">
|
||||
↻ Sync with EHR
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-link active" onclick="openTab(event, 'observations')">Observations ({{len .Observations}})</button>
|
||||
<button class="tab-link" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
||||
<button class="tab-link" onclick="openTab(event, 'notes')">Clinical Notes ({{len .DocumentReferences}})</button>
|
||||
<button class="tab-link" onclick="openTab(event, 'smart')">SMART Inspector</button>
|
||||
</div>
|
||||
|
||||
<div id="observations" class="tab-content" style="display:block;">
|
||||
{{if .Observations}}
|
||||
<table class="fhir-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Code</th>
|
||||
<th>Value</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Observations}}
|
||||
<tr>
|
||||
<td>{{orDash .EffectiveDate}}</td>
|
||||
<td>{{orDash .CodeText}}</td>
|
||||
<td>
|
||||
{{if .ValueQuantity}}
|
||||
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}
|
||||
{{else if .ValueString}}
|
||||
{{.ValueString}}
|
||||
{{else}}
|
||||
—
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge badge-outline">{{.Status}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted mt-4">No observations found. Use "Sync with EHR" to pull data.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="conditions" class="tab-content" style="display:none;">
|
||||
{{if .Conditions}}
|
||||
<table class="fhir-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recorded Date</th>
|
||||
<th>Code</th>
|
||||
<th>Clinical Status</th>
|
||||
<th>Verification</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Conditions}}
|
||||
<tr>
|
||||
<td>{{orDash .RecordedDate}}</td>
|
||||
<td>{{orDash .CodeText}}</td>
|
||||
<td>{{titleCase .ClinicalStatus}}</td>
|
||||
<td>{{titleCase .VerificationStatus}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted mt-4">No conditions found. Use "Sync with EHR" to pull data.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="notes" class="tab-content" style="display:none;">
|
||||
{{if .DocumentReferences}}
|
||||
<table class="fhir-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DocumentReferences}}
|
||||
<tr>
|
||||
<td>{{orDash .Date}}</td>
|
||||
<td>{{orDash .TypeText}}</td>
|
||||
<td>{{orDash .Description}}</td>
|
||||
<td><span class="badge badge-outline">{{orDash .Status}}</span></td>
|
||||
<td>
|
||||
{{if .ContentURL}}
|
||||
<a href="{{.ContentURL}}" target="_blank" rel="noopener noreferrer" style="font-size:.8rem;">View</a>
|
||||
{{else if .ContentData}}
|
||||
<span class="text-muted" style="font-size:.8rem;">Inline ({{.ContentType}})</span>
|
||||
{{else}}
|
||||
—
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted mt-4">No clinical notes found. Use "Sync with EHR" to pull data.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="smart" class="tab-content" style="display:none;">
|
||||
<div class="mt-4">
|
||||
<div class="detail-item mb-4">
|
||||
<label>FHIR Base URL (ISS)</label>
|
||||
<p class="text-muted">{{.Session.EHRURL}}</p>
|
||||
</div>
|
||||
<div class="detail-item mb-4">
|
||||
<label>Granted Scopes</label>
|
||||
<p class="text-muted">{{.Session.Scope}}</p>
|
||||
</div>
|
||||
<div class="detail-item mb-4">
|
||||
<label>Access Token</label>
|
||||
<code style="word-break: break-all; font-size: 0.75rem; background: var(--color-bg); padding: 8px; border-radius: 4px; display: block;">{{.Session.AccessToken}}</code>
|
||||
</div>
|
||||
{{if .Session.IDToken}}
|
||||
<div class="detail-item mt-4">
|
||||
<label>ID Token</label>
|
||||
<code style="word-break: break-all; font-size: 0.75rem; background: var(--color-bg); padding: 8px; border-radius: 4px; display: block;">{{.Session.IDToken}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---- Raw FHIR resource accordion ---- */}}
|
||||
{{if .RawPatient}}
|
||||
<details style="margin-bottom:20px;">
|
||||
<summary style="cursor:pointer; font-size:.875rem; font-weight:600; color:var(--color-primary); padding:8px 0;">
|
||||
View Raw FHIR Patient Resource
|
||||
</summary>
|
||||
<div class="card" style="margin-top:8px;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.85rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid var(--color-border);">
|
||||
<th style="text-align:left;padding:6px 12px 8px;color:var(--color-muted);font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;">Field</th>
|
||||
<th style="text-align:left;padding:6px 12px 8px;color:var(--color-muted);font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">resourceType</td>
|
||||
<td style="padding:7px 12px;">{{.RawPatient.ResourceTypeField}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">id</td>
|
||||
<td style="padding:7px 12px;">{{.RawPatient.ID}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">gender</td>
|
||||
<td style="padding:7px 12px;">{{.RawPatient.Gender}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">birthDate</td>
|
||||
<td style="padding:7px 12px;">{{.RawPatient.BirthDate}}</td>
|
||||
</tr>
|
||||
{{range .RawPatient.Name}}
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">name ({{.Use}})</td>
|
||||
<td style="padding:7px 12px;">
|
||||
{{range .Given}}{{.}} {{end}}{{.Family}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{range .RawPatient.Telecom}}
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">telecom ({{.System}})</td>
|
||||
<td style="padding:7px 12px;">{{.Value}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{range .RawPatient.Address}}
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:7px 12px;font-weight:600;">address</td>
|
||||
<td style="padding:7px 12px;">
|
||||
{{range .Line}}{{.}}, {{end}}{{.City}}{{if .State}}, {{.State}}{{end}} {{.PostalCode}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{end}}{{/* end if .Patient */}}
|
||||
|
||||
{{end}}{{/* end content */}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tablinks;
|
||||
tabcontent = document.getElementsByClassName("tab-content");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tablinks = document.getElementsByClassName("tab-link");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
13
app/templates/error.html
Normal file
13
app/templates/error.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "nav"}}
|
||||
<a href="/">Home</a>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="hero">
|
||||
<h1>{{.Code}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<a class="btn btn-outline" href="/">Go Home</a>
|
||||
</div>
|
||||
{{end}}
|
||||
15
app/templates/index.html
Normal file
15
app/templates/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "nav"}}
|
||||
<a href="/">Home</a>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="hero">
|
||||
<h1>FHIR Health Platform</h1>
|
||||
<p>A SMART on FHIR-powered platform for secure, context-aware EHR integration. Launch from your EHR to get started.</p>
|
||||
<a class="btn btn-primary" href="https://launch.smarthealthit.org/?launch_url=http%3A%2F%2Flocalhost%3A8080%2Flaunch" target="_blank" rel="noopener">
|
||||
Launch with SmartHealthIT Sandbox
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
14
go.mod
14
go.mod
@@ -1,3 +1,17 @@
|
||||
module github.com/AmanTahiliani/FHIR-Sandbox
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
||||
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