Improved FHIR handling

This commit is contained in:
2026-02-20 17:17:26 -05:00
parent 63cb2b7b1c
commit f21d2039cb
16 changed files with 1188 additions and 131 deletions

View File

@@ -77,7 +77,7 @@ import (
- **Exported items:** `PascalCase`. - **Exported items:** `PascalCase`.
- **Unexported items:** `camelCase`. - **Unexported items:** `camelCase`.
- **Receiver names:** Use 1-3 letter abbreviations (e.g., `func (app *Application) ...`). - **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. - **Variables:** Use short names for short-lived variables (`err`, `w`, `r`) and descriptive names for long-lived ones.
### Formatting ### 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`. - **Discovery:** Always use the `.well-known/smart-configuration` endpoint to find `authorization_endpoint` and `token_endpoint`.
### Security ### 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. - **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. - **Bearer Tokens:** Always include the `Authorization: Bearer <token>` header when fetching FHIR resources.
### 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. - 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 ## 4. Project Structure
@@ -132,24 +138,34 @@ The project is organised into modular packages under `/app`:
- `/app/config`: Configuration structures and URL normalisation. - `/app/config`: Configuration structures and URL normalisation.
- `/app/db`: SQLite storage, versioned migrations, and CRUD operations. - `/app/db`: SQLite storage, versioned migrations, and CRUD operations.
- `/app/fhir`: FHIR R4 type definitions, SMART discovery, and FHIR client. - `/app/fhir`: FHIR R4 type definitions, SMART discovery, and FHIR client.
- `/app/handlers`: HTTP handlers and per-render template logic. - `/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 and auth guards. - `/app/middleware`: Session management middleware (session loading, hard-gate protection).
- `/app/models`: Core domain models and context keys. - `/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. - `app/main.go`: Application entry point and dependency wiring.
- `go.mod`: Go module definition (v1.24.0). - `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: 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). 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`. 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. 3. **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions without requiring re-authentication.
4. **FHIR Resources:** Add support for additional resources like Observations, Conditions, and Encounters. 4. **Additional FHIR Resources:** Add support for more resources like Encounters, Procedures, Immunizations, etc.
5. **Frontend:** Evolve the current templates into a more dynamic UI (e.g., using HTMX or a modern JS framework if appropriate). 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 Types:** Consider using a comprehensive FHIR library (e.g., `google/fhir/go`) for type-safe resource handling as the scope grows. 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.* *Created by AI Agent. Updated Feb 2026.*

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -32,21 +32,25 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
if err == nil { if err == nil {
_, err = s.db.Exec(` _, err = s.db.Exec(`
UPDATE observations SET UPDATE observations SET
patient_fhir_id = ?, patient_fhir_id = ?,
status = ?, status = ?,
category = ?, category = ?,
code_text = ?, code_text = ?,
code_system = ?, code_system = ?,
code_code = ?, code_code = ?,
effective_date = ?, effective_date = ?,
value_quantity = ?, value_quantity = ?,
value_unit = ?, value_unit = ?,
value_string = ?, value_string = ?,
synced_at = ? interpretation = ?,
ref_range_low = ?,
ref_range_high = ?,
synced_at = ?
WHERE id = ?`, WHERE id = ?`,
o.PatientFHIRID, o.Status, o.Category, o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode, o.CodeText, o.CodeSystem, o.CodeCode,
o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString, o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString,
o.Interpretation, o.ReferenceRangeLow, o.ReferenceRangeHigh,
now, existingID, now, existingID,
) )
if err != nil { if err != nil {
@@ -60,11 +64,13 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
INSERT INTO observations ( INSERT INTO observations (
id, fhir_id, ehr_url, patient_fhir_id, status, category, id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date, code_text, code_system, code_code, effective_date,
value_quantity, value_unit, value_string, synced_at value_quantity, value_unit, value_string, interpretation,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ref_range_low, ref_range_high, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category, id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate, 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 { if err != nil {
return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err) 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(` rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category, SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date, 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 FROM observations
WHERE patient_fhir_id = ? AND ehr_url = ? WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY effective_date DESC`, ORDER BY effective_date DESC`,
@@ -94,7 +101,8 @@ func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observa
if err := rows.Scan( if err := rows.Scan(
&o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category, &o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category,
&o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate, &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 { ); err != nil {
return nil, fmt.Errorf("db: scan observation: %w", err) 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() 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 // PatientSync
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -193,6 +193,52 @@ var migrations = []migration{
CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url); CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url);
`, `,
}, },
{
version: 4,
sql: `
ALTER TABLE observations ADD COLUMN interpretation TEXT NOT NULL DEFAULT '';
ALTER TABLE observations ADD COLUMN ref_range_low REAL;
ALTER TABLE observations ADD COLUMN ref_range_high REAL;
CREATE TABLE IF NOT EXISTS medication_requests (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
intent TEXT NOT NULL DEFAULT '',
med_code_text TEXT NOT NULL DEFAULT '',
med_code_system TEXT NOT NULL DEFAULT '',
med_code_code TEXT NOT NULL DEFAULT '',
authored_on TEXT NOT NULL DEFAULT '',
requester_display TEXT NOT NULL DEFAULT '',
dosage_text TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS allergy_intolerances (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
clinical_status TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
criticality TEXT NOT NULL DEFAULT '',
code_text TEXT NOT NULL DEFAULT '',
code_system TEXT NOT NULL DEFAULT '',
code_code TEXT NOT NULL DEFAULT '',
recorded_date TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE INDEX IF NOT EXISTS idx_medication_requests_patient ON medication_requests(patient_fhir_id, ehr_url);
CREATE INDEX IF NOT EXISTS idx_allergy_intolerances_patient ON allergy_intolerances(patient_fhir_id, ehr_url);
`,
},
// Future migrations: append new entries here with incrementing version numbers. // Future migrations: append new entries here with incrementing version numbers.
// Example: // Example:
// { // {
@@ -349,6 +395,36 @@ func (s *Store) GetUserByID(id string) (*models.User, error) {
return u, nil 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 // Session operations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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 // Session tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -160,18 +160,37 @@ func (p *Practitioner) ResourceType() string { return "Practitioner" }
// Clinical resources (R4) // 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. // Observation represents a FHIR R4 Observation resource.
// https://www.hl7.org/fhir/observation.html // https://www.hl7.org/fhir/observation.html
type Observation struct { type Observation struct {
ResourceTypeField string `json:"resourceType"` ResourceTypeField string `json:"resourceType"`
ID string `json:"id"` ID string `json:"id"`
Status string `json:"status"` Status string `json:"status"`
Category []CodeableConcept `json:"category"` Category []CodeableConcept `json:"category"`
Code CodeableConcept `json:"code"` Code CodeableConcept `json:"code"`
Subject Reference `json:"subject"` Subject Reference `json:"subject"`
EffectiveDateTime string `json:"effectiveDateTime"` EffectiveDateTime string `json:"effectiveDateTime"`
ValueQuantity *Quantity `json:"valueQuantity,omitempty"` ValueQuantity *Quantity `json:"valueQuantity,omitempty"`
ValueString string `json:"valueString,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" } func (o *Observation) ResourceType() string { return "Observation" }
@@ -225,6 +244,54 @@ type DocumentReference struct {
func (d *DocumentReference) ResourceType() string { return "DocumentReference" } 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. // Quantity represents the FHIR Quantity data type.
type Quantity struct { type Quantity struct {
Value float64 `json:"value"` Value float64 `json:"value"`
@@ -233,11 +300,18 @@ type Quantity struct {
Code string `json:"code"` 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. // Bundle represents a FHIR R4 Bundle resource, used for search results.
type Bundle struct { type Bundle struct {
ResourceType string `json:"resourceType"` ResourceType string `json:"resourceType"`
Type string `json:"type"` Type string `json:"type"`
Total int `json:"total"` Total int `json:"total"`
Link []BundleLink `json:"link"`
Entry []struct { Entry []struct {
FullUrl string `json:"fullUrl"` FullUrl string `json:"fullUrl"`
Resource json.RawMessage `json:"resource"` Resource json.RawMessage `json:"resource"`
@@ -275,8 +349,8 @@ type TokenResponse struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
// SMART launch context extensions // SMART launch context extensions
Patient string `json:"patient"` Patient string `json:"patient"`
Encounter string `json:"encounter"` Encounter string `json:"encounter"`
// Practitioner holds a bare Practitioner FHIR ID when provided by the EHR. // Practitioner holds a bare Practitioner FHIR ID when provided by the EHR.
Practitioner string `json:"practitioner"` Practitioner string `json:"practitioner"`
// User holds a relative FHIR reference to the authenticated user, // 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 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. // GetPatient fetches a Patient resource by FHIR ID.
func (c *Client) GetPatient(id string) (*Patient, error) { func (c *Client) GetPatient(id string) (*Patient, error) {
var p Patient var p Patient
@@ -359,17 +480,26 @@ func (c *Client) GetPractitioner(id string) (*Practitioner, error) {
} }
// GetObservations fetches Observation resources for a specific patient. // 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 var bundle Bundle
path := fmt.Sprintf("Observation?patient=%s&_sort=-date", patientID) 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 { if err := c.get(path, &bundle); err != nil {
return nil, err return nil, err
} }
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var observations []Observation var observations []Observation
for _, entry := range bundle.Entry { for _, entry := range entries {
var o Observation var o Observation
if err := json.Unmarshal(entry.Resource, &o); err == nil { if err := json.Unmarshal(entry, &o); err == nil {
observations = append(observations, o) observations = append(observations, o)
} }
} }
@@ -377,17 +507,26 @@ func (c *Client) GetObservations(patientID string) ([]Observation, error) {
} }
// GetConditions fetches Condition resources for a specific patient. // 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 var bundle Bundle
path := fmt.Sprintf("Condition?patient=%s", patientID) path := fmt.Sprintf("Condition?patient=%s", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil { if err := c.get(path, &bundle); err != nil {
return nil, err return nil, err
} }
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var conditions []Condition var conditions []Condition
for _, entry := range bundle.Entry { for _, entry := range entries {
var cond Condition var cond Condition
if err := json.Unmarshal(entry.Resource, &cond); err == nil { if err := json.Unmarshal(entry, &cond); err == nil {
conditions = append(conditions, cond) conditions = append(conditions, cond)
} }
} }
@@ -396,23 +535,86 @@ func (c *Client) GetConditions(patientID string) ([]Condition, error) {
// GetDocumentReferences fetches DocumentReference resources for a specific patient. // GetDocumentReferences fetches DocumentReference resources for a specific patient.
// Results are sorted newest-first by date. // 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 var bundle Bundle
path := fmt.Sprintf("DocumentReference?patient=%s&_sort=-date", patientID) 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 { if err := c.get(path, &bundle); err != nil {
return nil, err return nil, err
} }
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var docs []DocumentReference var docs []DocumentReference
for _, entry := range bundle.Entry { for _, entry := range entries {
var d DocumentReference var d DocumentReference
if err := json.Unmarshal(entry.Resource, &d); err == nil { if err := json.Unmarshal(entry, &d); err == nil {
docs = append(docs, d) docs = append(docs, d)
} }
} }
return docs, nil 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 // GetSmartConfiguration fetches and parses the SMART discovery document
// for this FHIR server. // for this FHIR server.
func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) { func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) {
@@ -552,24 +754,78 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob
coding := firstCoding(o.Code) coding := firstCoding(o.Code)
var qty *float64 var qty *float64
var unit string var unit string
var valueStr string
if o.ValueQuantity != nil { if o.ValueQuantity != nil {
v := o.ValueQuantity.Value v := o.ValueQuantity.Value
qty = &v qty = &v
unit = o.ValueQuantity.Unit 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{ return &models.Observation{
FHIRID: o.ID, FHIRID: o.ID,
EHRURL: strings.TrimRight(ehrURL, "/"), EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID, PatientFHIRID: patientFHIRID,
Status: o.Status, Status: o.Status,
Category: firstCategoryText(o.Category), Category: firstCategoryText(o.Category),
CodeText: o.Code.Text, CodeText: o.Code.Text,
CodeSystem: coding.System, CodeSystem: coding.System,
CodeCode: coding.Code, CodeCode: coding.Code,
EffectiveDate: o.EffectiveDateTime, EffectiveDate: o.EffectiveDateTime,
ValueQuantity: qty, ValueQuantity: qty,
ValueUnit: unit, ValueUnit: unit,
ValueString: o.ValueString, 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 // ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference
// (e.g. "Practitioner/123" or "Patient/abc") from the id_token's fhirUser claim. // (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. // Returns an empty string if the claim is missing or invalid.

View File

@@ -9,8 +9,8 @@
// 5. Fetch the Patient FHIR resource using the access token. // 5. Fetch the Patient FHIR resource using the access token.
// 6. Resolve the practitioner from the token response. The SMART spec allows // 6. Resolve the practitioner from the token response. The SMART spec allows
// the practitioner to appear in two places — we handle both: // the practitioner to appear in two places — we handle both:
// a. tokenResp.Practitioner — a bare FHIR ID (some EHRs) // a. tokenResp.Practitioner — a bare FHIR ID (some EHRs)
// b. tokenResp.User — a relative reference "Practitioner/<id>" (SmartHealthIT) // b. tokenResp.User — a relative reference "Practitioner/<id>" (SmartHealthIT)
// 7. Upsert both users into the database. // 7. Upsert both users into the database.
// 8. Create a server-side session for the HCP and set the session cookie. // 8. Create a server-side session for the HCP and set the session cookie.
// 9. Render the patient dashboard. // 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 bare ID (if the context implies it): "123"
// - A relative reference: "Practitioner/123" // - A relative reference: "Practitioner/123"
// - An absolute FHIR URL: "https://ehr.com/fhir/Practitioner/123" // - An absolute FHIR URL: "https://ehr.com/fhir/Practitioner/123"
//
// Returns an empty string if the value is not a Practitioner reference. // Returns an empty string if the value is not a Practitioner reference.
func parsePractitionerFromUserField(user string) string { func parsePractitionerFromUserField(user string) string {
// If it's a URL, take the path part. // If it's a URL, take the path part.
@@ -214,7 +215,7 @@ func parsePractitionerFromUserField(user string) string {
if idx := strings.Index(user, prefix); idx != -1 { if idx := strings.Index(user, prefix); idx != -1 {
return strings.TrimPrefix(user[idx:], prefix) return strings.TrimPrefix(user[idx:], prefix)
} }
return "" return ""
} }

View File

@@ -10,7 +10,8 @@ import (
) )
// HandleDashboard renders the stable patient dashboard. // 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. // made here. Use POST /dashboard/sync to refresh data from the EHR.
// //
// GET /dashboard // GET /dashboard
@@ -26,6 +27,27 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
ehrURL := sess.EHRURL ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID 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 // Fetch patient demographics from the FHIR server. This is a cheap single
// resource call and keeps the patient card always current. // resource call and keeps the patient card always current.
fhirClient := fhir.NewClient(ehrURL, sess.AccessToken) 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) 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 { 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{ h.render(w, "dashboard.html", dashboardData{
Patient: patientUser, Patient: patientUser,
Practitioner: practitionerUser, Practitioner: practitionerUser,
@@ -66,8 +95,11 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
Observations: observations, Observations: observations,
Conditions: conditions, Conditions: conditions,
DocumentReferences: docRefs, DocumentReferences: docRefs,
Medications: medications,
Allergies: allergies,
LatestSync: latestSync, LatestSync: latestSync,
Session: sess, Session: sess,
Synced: synced,
}) })
} }
@@ -79,8 +111,11 @@ type dashboardData struct {
Observations []models.Observation Observations []models.Observation
Conditions []models.Condition Conditions []models.Condition
DocumentReferences []models.DocumentReference DocumentReferences []models.DocumentReference
Medications []models.MedicationRequest
Allergies []models.AllergyIntolerance
LatestSync *models.PatientSync LatestSync *models.PatientSync
Session *models.Session Session *models.Session
Synced bool
} }
// handleUnauthorized redirects to root for dashboard requests. // handleUnauthorized redirects to root for dashboard requests.

View File

@@ -29,6 +29,7 @@ import (
"github.com/AmanTahiliani/FHIR-Sandbox/app/config" "github.com/AmanTahiliani/FHIR-Sandbox/app/config"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db" "github.com/AmanTahiliani/FHIR-Sandbox/app/db"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
) )
const ( const (
@@ -152,5 +153,29 @@ func TemplateFuncs() template.FuncMap {
} }
return s 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
View 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
}

View File

@@ -3,18 +3,24 @@ package handlers
import ( import (
"log" "log"
"net/http" "net/http"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
) )
// HandleSync performs a live FHIR pull for Observations, Conditions, and // HandleSync performs a live FHIR pull for Observations, Conditions, DocumentReferences,
// DocumentReferences for the session's patient, upserts all results into the // MedicationRequests, and AllergyIntolerances for the session's patient, upserts all
// database, records a PatientSync event, then redirects back to GET /dashboard. // 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) { 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) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
@@ -27,12 +33,29 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
ehrURL := sess.EHRURL ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID 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) client := fhir.NewClient(ehrURL, sess.AccessToken)
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Fetch Observations // Fetch Observations
// ----------------------------------------------------------------- // -----------------------------------------------------------------
rawObs, err := client.GetObservations(patientID) rawObs, err := client.GetObservations(patientID, sinceTime)
if err != nil { if err != nil {
log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err) log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err)
// Non-fatal; continue with whatever we got. // Non-fatal; continue with whatever we got.
@@ -51,7 +74,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Fetch Conditions // Fetch Conditions
// ----------------------------------------------------------------- // -----------------------------------------------------------------
rawConds, err := client.GetConditions(patientID) rawConds, err := client.GetConditions(patientID, sinceTime)
if err != nil { if err != nil {
log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err) 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 // Fetch DocumentReferences
// ----------------------------------------------------------------- // -----------------------------------------------------------------
rawDocs, err := client.GetDocumentReferences(patientID) rawDocs, err := client.GetDocumentReferences(patientID, sinceTime)
if err != nil { if err != nil {
log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err) 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++ 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 // 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 RecordSync Patient/%s: %v", patientID, err)
} }
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d", log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d",
patientID, obsCount, condCount, docCount) 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)
} }

View File

@@ -96,6 +96,7 @@ func main() {
// Session-required routes — wrapped with the hard-gate middleware. // Session-required routes — wrapped with the hard-gate middleware.
mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard))) mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard)))
mux.Handle("/dashboard/sync", sessionMW.RequireSession(http.HandlerFunc(h.HandleSync))) 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))) mux.Handle("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout)))
// Apply the soft session loader to every request so templates can always // Apply the soft session loader to every request so templates can always

View File

@@ -107,20 +107,23 @@ type UserContextKey struct{}
// Observation is the persisted representation of a FHIR R4 Observation. // Observation is the persisted representation of a FHIR R4 Observation.
// The natural key is (fhir_id, ehr_url). // The natural key is (fhir_id, ehr_url).
type Observation struct { type Observation struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"` FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"` EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"` Status string `json:"status" db:"status"`
Category string `json:"category" db:"category"` Category string `json:"category" db:"category"`
CodeText string `json:"code_text" db:"code_text"` CodeText string `json:"code_text" db:"code_text"`
CodeSystem string `json:"code_system" db:"code_system"` CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"` CodeCode string `json:"code_code" db:"code_code"`
EffectiveDate string `json:"effective_date" db:"effective_date"` EffectiveDate string `json:"effective_date" db:"effective_date"`
ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"` ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"`
ValueUnit string `json:"value_unit" db:"value_unit"` ValueUnit string `json:"value_unit" db:"value_unit"`
ValueString string `json:"value_string" db:"value_string"` ValueString string `json:"value_string" db:"value_string"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"` 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. // 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"` 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. // PatientSync records a completed FHIR sync event for a patient.
type PatientSync struct { type PatientSync struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`

View File

@@ -1,6 +1,7 @@
{{template "base.html" .}} {{template "base.html" .}}
{{define "nav"}} {{define "nav"}}
<a href="/patients">All Patients</a>
<a href="/">Home</a> <a href="/">Home</a>
<form action="/logout" method="POST" style="display:inline"> <form action="/logout" method="POST" style="display:inline">
<button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button> <button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button>
@@ -9,6 +10,19 @@
{{define "content"}} {{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;">&times;</button>
</div>
</div>
{{end}}
{{/* ---- Practitioner block ---- */}} {{/* ---- Practitioner block ---- */}}
{{if .Practitioner}} {{if .Practitioner}}
<div class="card"> <div class="card">
@@ -101,6 +115,25 @@
</div> </div>
</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 ---- */}} {{/* ---- Clinical Data block ---- */}}
<div class="card"> <div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
@@ -109,14 +142,12 @@
{{if .LatestSync}} {{if .LatestSync}}
<span class="text-muted" style="font-size:.8rem;"> <span class="text-muted" style="font-size:.8rem;">
Last synced: {{formatDateTime .LatestSync.SyncedAt}} Last synced: {{formatDateTime .LatestSync.SyncedAt}}
&nbsp;&middot;&nbsp;
{{.LatestSync.ObsCount}} obs &middot; {{.LatestSync.CondCount}} cond &middot; {{.LatestSync.DocCount}} docs
</span> </span>
{{else}} {{else}}
<span class="text-muted" style="font-size:.8rem;">Never synced</span> <span class="text-muted" style="font-size:.8rem;">Never synced</span>
{{end}} {{end}}
<form action="/dashboard/sync" method="POST" style="display:inline;"> <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;"> <button class="btn btn-primary" type="submit" style="padding:6px 14px;font-size:.875rem;" id="syncBtn" onclick="onSyncClick()">
&#x21bb;&nbsp;Sync with EHR &#x21bb;&nbsp;Sync with EHR
</button> </button>
</form> </form>
@@ -124,49 +155,75 @@
</div> </div>
<div class="tabs"> <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, '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, 'notes')">Clinical Notes ({{len .DocumentReferences}})</button>
<button class="tab-link" onclick="openTab(event, 'smart')">SMART Inspector</button>
</div> </div>
{{/* ---- Observations Tab (Grouped by Category) ---- */}}
<div id="observations" class="tab-content" style="display:block;"> <div id="observations" class="tab-content" style="display:block;">
{{if .Observations}} {{if .Observations}}
<table class="fhir-table"> {{range $cat, $obs := groupByCategory .Observations}}
<thead> <div style="margin-bottom:20px;">
<tr> <h4 style="margin-bottom:12px;color:#333;">{{titleCase $cat}}</h4>
<th>Date</th> <table class="fhir-table">
<th>Code</th> <thead>
<th>Value</th> <tr>
<th>Status</th> <th>Date</th>
</tr> <th>Code</th>
</thead> <th>Value</th>
<tbody> <th style="width:60px;">Interpretation</th>
{{range .Observations}} <th>Status</th>
<tr> </tr>
<td>{{orDash .EffectiveDate}}</td> </thead>
<td>{{orDash .CodeText}}</td> <tbody>
<td> {{range $obs}}
{{if .ValueQuantity}} <tr>
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}} <td>{{orDash .EffectiveDate}}</td>
{{else if .ValueString}} <td>{{orDash .CodeText}}</td>
{{.ValueString}} <td>
{{else}} {{if .ValueQuantity}}
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}
{{end}} {{if or .ReferenceRangeLow .ReferenceRangeHigh}}
</td> <br/><span style="font-size:.8rem;color:#666;">
<td><span class="badge badge-outline">{{.Status}}</span></td> [{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
</tr> </span>
{{end}} {{end}}
</tbody> {{else if .ValueString}}
</table> {{.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}} {{else}}
<p class="text-muted mt-4">No observations found. Use "Sync with EHR" to pull data.</p> <p class="text-muted mt-4">No observations found. Use "Sync with EHR" to pull data.</p>
{{end}} {{end}}
</div> </div>
{{/* ---- Conditions Tab (with filter bar) ---- */}}
<div id="conditions" class="tab-content" style="display:none;"> <div id="conditions" class="tab-content" style="display:none;">
{{if .Conditions}} {{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"> <table class="fhir-table">
<thead> <thead>
<tr> <tr>
@@ -178,7 +235,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .Conditions}} {{range .Conditions}}
<tr> <tr class="condition-row" data-status="{{.ClinicalStatus}}">
<td>{{orDash .RecordedDate}}</td> <td>{{orDash .RecordedDate}}</td>
<td>{{orDash .CodeText}}</td> <td>{{orDash .CodeText}}</td>
<td>{{titleCase .ClinicalStatus}}</td> <td>{{titleCase .ClinicalStatus}}</td>
@@ -192,6 +249,75 @@
{{end}} {{end}}
</div> </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;"> <div id="notes" class="tab-content" style="display:none;">
{{if .DocumentReferences}} {{if .DocumentReferences}}
<table class="fhir-table"> <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> <p class="text-muted mt-4">No clinical notes found. Use "Sync with EHR" to pull data.</p>
{{end}} {{end}}
</div> </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="mt-4">
<div class="detail-item mb-4"> <div class="detail-item mb-4">
<label>FHIR Base URL (ISS)</label> <label>FHIR Base URL (ISS)</label>
@@ -251,7 +383,7 @@
{{end}} {{end}}
</div> </div>
</div> </div>
</div> </details>
{{/* ---- Raw FHIR resource accordion ---- */}} {{/* ---- Raw FHIR resource accordion ---- */}}
{{if .RawPatient}} {{if .RawPatient}}
@@ -331,5 +463,23 @@ function openTab(evt, tabName) {
document.getElementById(tabName).style.display = "block"; document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active"; 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> </script>
{{end}} {{end}}

View 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}}