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`.
|
- **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.*
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
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);
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
373
app/fhir/fhir.go
373
app/fhir/fhir.go
@@ -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.
|
||||||
|
|||||||
@@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
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 (
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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;">×</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}}
|
||||||
·
|
|
||||||
{{.LatestSync.ObsCount}} obs · {{.LatestSync.CondCount}} cond · {{.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()">
|
||||||
↻ Sync with EHR
|
↻ 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}}
|
||||||
|
|||||||
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