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