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

@@ -32,21 +32,25 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
if err == nil {
_, err = s.db.Exec(`
UPDATE observations SET
patient_fhir_id = ?,
status = ?,
category = ?,
code_text = ?,
code_system = ?,
code_code = ?,
effective_date = ?,
value_quantity = ?,
value_unit = ?,
value_string = ?,
synced_at = ?
patient_fhir_id = ?,
status = ?,
category = ?,
code_text = ?,
code_system = ?,
code_code = ?,
effective_date = ?,
value_quantity = ?,
value_unit = ?,
value_string = ?,
interpretation = ?,
ref_range_low = ?,
ref_range_high = ?,
synced_at = ?
WHERE id = ?`,
o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode,
o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString,
o.Interpretation, o.ReferenceRangeLow, o.ReferenceRangeHigh,
now, existingID,
)
if err != nil {
@@ -60,11 +64,13 @@ func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
INSERT INTO observations (
id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date,
value_quantity, value_unit, value_string, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
value_quantity, value_unit, value_string, interpretation,
ref_range_low, ref_range_high, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate,
o.ValueQuantity, o.ValueUnit, o.ValueString, now,
o.ValueQuantity, o.ValueUnit, o.ValueString, o.Interpretation,
o.ReferenceRangeLow, o.ReferenceRangeHigh, now,
)
if err != nil {
return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err)
@@ -77,7 +83,8 @@ func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observa
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date,
value_quantity, value_unit, value_string, synced_at
value_quantity, value_unit, value_string, interpretation,
ref_range_low, ref_range_high, synced_at
FROM observations
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY effective_date DESC`,
@@ -94,7 +101,8 @@ func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observa
if err := rows.Scan(
&o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category,
&o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate,
&o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.SyncedAt,
&o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.Interpretation,
&o.ReferenceRangeLow, &o.ReferenceRangeHigh, &o.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan observation: %w", err)
}
@@ -289,6 +297,181 @@ func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.D
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// MedicationRequest
// ---------------------------------------------------------------------------
// UpsertMedicationRequest inserts or updates a MedicationRequest record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertMedicationRequest(m *models.MedicationRequest) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM medication_requests WHERE fhir_id = ? AND ehr_url = ?`,
m.FHIRID, m.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE medication_requests SET
patient_fhir_id = ?,
status = ?,
intent = ?,
med_code_text = ?,
med_code_system = ?,
med_code_code = ?,
authored_on = ?,
requester_display = ?,
dosage_text = ?,
synced_at = ?
WHERE id = ?`,
m.PatientFHIRID, m.Status, m.Intent,
m.MedCodeText, m.MedCodeSystem, m.MedCodeCode,
m.AuthoredOn, m.RequesterDisplay, m.DosageText,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update medication_request %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO medication_requests (
id, fhir_id, ehr_url, patient_fhir_id,
status, intent, med_code_text, med_code_system, med_code_code,
authored_on, requester_display, dosage_text, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, m.FHIRID, m.EHRURL, m.PatientFHIRID,
m.Status, m.Intent, m.MedCodeText, m.MedCodeSystem, m.MedCodeCode,
m.AuthoredOn, m.RequesterDisplay, m.DosageText, now,
)
if err != nil {
return "", fmt.Errorf("db: insert medication_request fhir_id=%s: %w", m.FHIRID, err)
}
return id, nil
}
// ListMedicationRequests returns all MedicationRequests for the given patient, newest first.
func (s *Store) ListMedicationRequests(patientFHIRID, ehrURL string) ([]models.MedicationRequest, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
status, intent, med_code_text, med_code_system, med_code_code,
authored_on, requester_display, dosage_text, synced_at
FROM medication_requests
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY authored_on DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list medication_requests: %w", err)
}
defer rows.Close()
var out []models.MedicationRequest
for rows.Next() {
var m models.MedicationRequest
if err := rows.Scan(
&m.ID, &m.FHIRID, &m.EHRURL, &m.PatientFHIRID,
&m.Status, &m.Intent, &m.MedCodeText, &m.MedCodeSystem, &m.MedCodeCode,
&m.AuthoredOn, &m.RequesterDisplay, &m.DosageText, &m.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan medication_request: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// AllergyIntolerance
// ---------------------------------------------------------------------------
// UpsertAllergyIntolerance inserts or updates an AllergyIntolerance record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM allergy_intolerances WHERE fhir_id = ? AND ehr_url = ?`,
a.FHIRID, a.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE allergy_intolerances SET
patient_fhir_id = ?,
clinical_status = ?,
verification_status = ?,
type = ?,
category = ?,
criticality = ?,
code_text = ?,
code_system = ?,
code_code = ?,
recorded_date = ?,
synced_at = ?
WHERE id = ?`,
a.PatientFHIRID, a.ClinicalStatus, a.VerificationStatus,
a.Type, a.Category, a.Criticality,
a.CodeText, a.CodeSystem, a.CodeCode,
a.RecordedDate, now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update allergy_intolerance %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO allergy_intolerances (
id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, type, category, criticality,
code_text, code_system, code_code, recorded_date, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.FHIRID, a.EHRURL, a.PatientFHIRID,
a.ClinicalStatus, a.VerificationStatus, a.Type, a.Category, a.Criticality,
a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, now,
)
if err != nil {
return "", fmt.Errorf("db: insert allergy_intolerance fhir_id=%s: %w", a.FHIRID, err)
}
return id, nil
}
// ListAllergyIntolerances returns all AllergyIntolerances for the given patient, newest first.
func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models.AllergyIntolerance, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, type, category, criticality,
code_text, code_system, code_code, recorded_date, synced_at
FROM allergy_intolerances
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY recorded_date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list allergy_intolerances: %w", err)
}
defer rows.Close()
var out []models.AllergyIntolerance
for rows.Next() {
var a models.AllergyIntolerance
if err := rows.Scan(
&a.ID, &a.FHIRID, &a.EHRURL, &a.PatientFHIRID,
&a.ClinicalStatus, &a.VerificationStatus, &a.Type, &a.Category, &a.Criticality,
&a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, &a.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// PatientSync
// ---------------------------------------------------------------------------

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);
`,
},
{
version: 4,
sql: `
ALTER TABLE observations ADD COLUMN interpretation TEXT NOT NULL DEFAULT '';
ALTER TABLE observations ADD COLUMN ref_range_low REAL;
ALTER TABLE observations ADD COLUMN ref_range_high REAL;
CREATE TABLE IF NOT EXISTS medication_requests (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
intent TEXT NOT NULL DEFAULT '',
med_code_text TEXT NOT NULL DEFAULT '',
med_code_system TEXT NOT NULL DEFAULT '',
med_code_code TEXT NOT NULL DEFAULT '',
authored_on TEXT NOT NULL DEFAULT '',
requester_display TEXT NOT NULL DEFAULT '',
dosage_text TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS allergy_intolerances (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
clinical_status TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
criticality TEXT NOT NULL DEFAULT '',
code_text TEXT NOT NULL DEFAULT '',
code_system TEXT NOT NULL DEFAULT '',
code_code TEXT NOT NULL DEFAULT '',
recorded_date TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE INDEX IF NOT EXISTS idx_medication_requests_patient ON medication_requests(patient_fhir_id, ehr_url);
CREATE INDEX IF NOT EXISTS idx_allergy_intolerances_patient ON allergy_intolerances(patient_fhir_id, ehr_url);
`,
},
// Future migrations: append new entries here with incrementing version numbers.
// Example:
// {
@@ -349,6 +395,36 @@ func (s *Store) GetUserByID(id string) (*models.User, error) {
return u, nil
}
// ListUsersByRole retrieves all users with the given role and originating EHR URL.
func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, error) {
rows, err := s.db.Query(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
created_at, updated_at
FROM users WHERE role = ? AND ehr_url = ?
ORDER BY last_name ASC, first_name ASC`,
string(role), ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list users by role: %w", err)
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
if err := rows.Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("db: scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
// ---------------------------------------------------------------------------
// Session operations
// ---------------------------------------------------------------------------

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
// ---------------------------------------------------------------------------

View File

@@ -160,18 +160,37 @@ func (p *Practitioner) ResourceType() string { return "Practitioner" }
// Clinical resources (R4)
// ---------------------------------------------------------------------------
// ObservationComponent represents a component of an Observation (used for
// compound observations like blood pressure with systolic/diastolic values).
type ObservationComponent struct {
Code CodeableConcept `json:"code"`
ValueQuantity *Quantity `json:"valueQuantity,omitempty"`
ValueString string `json:"valueString,omitempty"`
}
// ObservationReferenceRange represents a reference range for an Observation.
type ObservationReferenceRange struct {
Low *Quantity `json:"low,omitempty"`
High *Quantity `json:"high,omitempty"`
Text string `json:"text,omitempty"`
Type CodeableConcept `json:"type,omitempty"`
}
// Observation represents a FHIR R4 Observation resource.
// https://www.hl7.org/fhir/observation.html
type Observation struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
Category []CodeableConcept `json:"category"`
Code CodeableConcept `json:"code"`
Subject Reference `json:"subject"`
EffectiveDateTime string `json:"effectiveDateTime"`
ValueQuantity *Quantity `json:"valueQuantity,omitempty"`
ValueString string `json:"valueString,omitempty"`
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
Category []CodeableConcept `json:"category"`
Code CodeableConcept `json:"code"`
Subject Reference `json:"subject"`
EffectiveDateTime string `json:"effectiveDateTime"`
ValueQuantity *Quantity `json:"valueQuantity,omitempty"`
ValueString string `json:"valueString,omitempty"`
Interpretation []CodeableConcept `json:"interpretation,omitempty"`
ReferenceRange []ObservationReferenceRange `json:"referenceRange,omitempty"`
Component []ObservationComponent `json:"component,omitempty"`
}
func (o *Observation) ResourceType() string { return "Observation" }
@@ -225,6 +244,54 @@ type DocumentReference struct {
func (d *DocumentReference) ResourceType() string { return "DocumentReference" }
// Dosage represents the FHIR Dosage data type (simplified).
type Dosage struct {
Text string `json:"text"`
Timing interface{} `json:"timing,omitempty"`
Route CodeableConcept `json:"route,omitempty"`
}
// DoseAndRate represents a dose and rate in a Dosage.
type DoseAndRate struct {
DoseQuantity *Quantity `json:"doseQuantity,omitempty"`
DoseRange interface{} `json:"doseRange,omitempty"`
RateQuantity *Quantity `json:"rateQuantity,omitempty"`
RateRange interface{} `json:"rateRange,omitempty"`
}
// MedicationRequest represents a FHIR R4 MedicationRequest resource.
// https://www.hl7.org/fhir/medicationrequest.html
type MedicationRequest struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
Intent string `json:"intent"`
MedicationCodeableConcept CodeableConcept `json:"medicationCodeableConcept"`
Subject Reference `json:"subject"`
AuthoredOn string `json:"authoredOn"`
Requester Reference `json:"requester"`
DosageInstruction []Dosage `json:"dosageInstruction"`
}
func (m *MedicationRequest) ResourceType() string { return "MedicationRequest" }
// AllergyIntolerance represents a FHIR R4 AllergyIntolerance resource.
// https://www.hl7.org/fhir/allergyintolerance.html
type AllergyIntolerance struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
ClinicalStatus CodeableConcept `json:"clinicalStatus"`
VerificationStatus CodeableConcept `json:"verificationStatus"`
Type string `json:"type"`
Category []string `json:"category"`
Criticality string `json:"criticality"`
Code CodeableConcept `json:"code"`
Patient Reference `json:"patient"`
RecordedDate string `json:"recordedDate"`
}
func (a *AllergyIntolerance) ResourceType() string { return "AllergyIntolerance" }
// Quantity represents the FHIR Quantity data type.
type Quantity struct {
Value float64 `json:"value"`
@@ -233,11 +300,18 @@ type Quantity struct {
Code string `json:"code"`
}
// BundleLink represents a link element in a Bundle (used for pagination).
type BundleLink struct {
Relation string `json:"relation"`
URL string `json:"url"`
}
// Bundle represents a FHIR R4 Bundle resource, used for search results.
type Bundle struct {
ResourceType string `json:"resourceType"`
Type string `json:"type"`
Total int `json:"total"`
ResourceType string `json:"resourceType"`
Type string `json:"type"`
Total int `json:"total"`
Link []BundleLink `json:"link"`
Entry []struct {
FullUrl string `json:"fullUrl"`
Resource json.RawMessage `json:"resource"`
@@ -275,8 +349,8 @@ type TokenResponse struct {
RefreshToken string `json:"refresh_token"`
// SMART launch context extensions
Patient string `json:"patient"`
Encounter string `json:"encounter"`
Patient string `json:"patient"`
Encounter string `json:"encounter"`
// Practitioner holds a bare Practitioner FHIR ID when provided by the EHR.
Practitioner string `json:"practitioner"`
// User holds a relative FHIR reference to the authenticated user,
@@ -340,6 +414,53 @@ func (c *Client) get(path string, dest interface{}) error {
return nil
}
// fetchAllBundlePages follows pagination links in a Bundle and accumulates all entries.
// It fetches the initial bundle and then follows 'next' links up to maxPages times.
// Returns a slice of raw JSON entries and any error encountered.
func (c *Client) fetchAllBundlePages(initialBundle *Bundle, maxPages int) ([]json.RawMessage, error) {
if maxPages < 1 {
maxPages = 1
}
var allEntries []json.RawMessage
for _, entry := range initialBundle.Entry {
allEntries = append(allEntries, entry.Resource)
}
currentBundle := initialBundle
pageCount := 1
for pageCount < maxPages {
nextURL := ""
for _, link := range currentBundle.Link {
if link.Relation == "next" {
nextURL = link.URL
break
}
}
if nextURL == "" {
break
}
// Extract path from absolute URL
var nextBundle Bundle
if err := c.get(strings.TrimPrefix(nextURL, c.baseURL+"/"), &nextBundle); err != nil {
// Don't fail on pagination error; return what we have so far
break
}
for _, entry := range nextBundle.Entry {
allEntries = append(allEntries, entry.Resource)
}
currentBundle = &nextBundle
pageCount++
}
return allEntries, nil
}
// GetPatient fetches a Patient resource by FHIR ID.
func (c *Client) GetPatient(id string) (*Patient, error) {
var p Patient
@@ -359,17 +480,26 @@ func (c *Client) GetPractitioner(id string) (*Practitioner, error) {
}
// GetObservations fetches Observation resources for a specific patient.
func (c *Client) GetObservations(patientID string) ([]Observation, error) {
// If since is non-empty, only fetches observations modified after that timestamp (RFC3339).
func (c *Client) GetObservations(patientID, since string) ([]Observation, error) {
var bundle Bundle
path := fmt.Sprintf("Observation?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var observations []Observation
for _, entry := range bundle.Entry {
for _, entry := range entries {
var o Observation
if err := json.Unmarshal(entry.Resource, &o); err == nil {
if err := json.Unmarshal(entry, &o); err == nil {
observations = append(observations, o)
}
}
@@ -377,17 +507,26 @@ func (c *Client) GetObservations(patientID string) ([]Observation, error) {
}
// GetConditions fetches Condition resources for a specific patient.
func (c *Client) GetConditions(patientID string) ([]Condition, error) {
// If since is non-empty, only fetches conditions modified after that timestamp (RFC3339).
func (c *Client) GetConditions(patientID, since string) ([]Condition, error) {
var bundle Bundle
path := fmt.Sprintf("Condition?patient=%s", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var conditions []Condition
for _, entry := range bundle.Entry {
for _, entry := range entries {
var cond Condition
if err := json.Unmarshal(entry.Resource, &cond); err == nil {
if err := json.Unmarshal(entry, &cond); err == nil {
conditions = append(conditions, cond)
}
}
@@ -396,23 +535,86 @@ func (c *Client) GetConditions(patientID string) ([]Condition, error) {
// GetDocumentReferences fetches DocumentReference resources for a specific patient.
// Results are sorted newest-first by date.
func (c *Client) GetDocumentReferences(patientID string) ([]DocumentReference, error) {
// If since is non-empty, only fetches documents modified after that timestamp (RFC3339).
func (c *Client) GetDocumentReferences(patientID, since string) ([]DocumentReference, error) {
var bundle Bundle
path := fmt.Sprintf("DocumentReference?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var docs []DocumentReference
for _, entry := range bundle.Entry {
for _, entry := range entries {
var d DocumentReference
if err := json.Unmarshal(entry.Resource, &d); err == nil {
if err := json.Unmarshal(entry, &d); err == nil {
docs = append(docs, d)
}
}
return docs, nil
}
// GetMedicationRequests fetches MedicationRequest resources for a specific patient.
// If since is non-empty, only fetches requests modified after that timestamp (RFC3339).
func (c *Client) GetMedicationRequests(patientID, since string) ([]MedicationRequest, error) {
var bundle Bundle
path := fmt.Sprintf("MedicationRequest?patient=%s&status=active&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var requests []MedicationRequest
for _, entry := range entries {
var m MedicationRequest
if err := json.Unmarshal(entry, &m); err == nil {
requests = append(requests, m)
}
}
return requests, nil
}
// GetAllergyIntolerances fetches AllergyIntolerance resources for a specific patient.
// If since is non-empty, only fetches allergies modified after that timestamp (RFC3339).
func (c *Client) GetAllergyIntolerances(patientID, since string) ([]AllergyIntolerance, error) {
var bundle Bundle
path := fmt.Sprintf("AllergyIntolerance?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var allergies []AllergyIntolerance
for _, entry := range entries {
var a AllergyIntolerance
if err := json.Unmarshal(entry, &a); err == nil {
allergies = append(allergies, a)
}
}
return allergies, nil
}
// GetSmartConfiguration fetches and parses the SMART discovery document
// for this FHIR server.
func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) {
@@ -552,24 +754,78 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob
coding := firstCoding(o.Code)
var qty *float64
var unit string
var valueStr string
if o.ValueQuantity != nil {
v := o.ValueQuantity.Value
qty = &v
unit = o.ValueQuantity.Unit
valueStr = o.ValueString
} else if len(o.Component) > 0 && o.ValueQuantity == nil {
// Handle compound observations like blood pressure (systolic/diastolic)
// Format: "value1/value2 unit" (e.g., "120/80 mmHg")
var values []string
var compUnit string
for _, comp := range o.Component {
if comp.ValueQuantity != nil {
values = append(values, fmt.Sprintf("%.0f", comp.ValueQuantity.Value))
if compUnit == "" {
compUnit = comp.ValueQuantity.Unit
}
}
}
if len(values) > 0 {
valueStr = strings.Join(values, "/")
if compUnit != "" {
valueStr += " " + compUnit
}
unit = compUnit
}
} else {
valueStr = o.ValueString
}
// Extract interpretation (first coding display or code)
var interpretation string
if len(o.Interpretation) > 0 {
interp := firstCoding(o.Interpretation[0])
if interp.Display != "" {
interpretation = interp.Display
} else {
interpretation = interp.Code
}
}
// Extract reference range (low and high from first range entry)
var refRangeLow, refRangeHigh *float64
if len(o.ReferenceRange) > 0 {
refRange := o.ReferenceRange[0]
if refRange.Low != nil {
v := refRange.Low.Value
refRangeLow = &v
}
if refRange.High != nil {
v := refRange.High.Value
refRangeHigh = &v
}
}
return &models.Observation{
FHIRID: o.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: o.Status,
Category: firstCategoryText(o.Category),
CodeText: o.Code.Text,
CodeSystem: coding.System,
CodeCode: coding.Code,
EffectiveDate: o.EffectiveDateTime,
ValueQuantity: qty,
ValueUnit: unit,
ValueString: o.ValueString,
FHIRID: o.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: o.Status,
Category: firstCategoryText(o.Category),
CodeText: o.Code.Text,
CodeSystem: coding.System,
CodeCode: coding.Code,
EffectiveDate: o.EffectiveDateTime,
ValueQuantity: qty,
ValueUnit: unit,
ValueString: valueStr,
Interpretation: interpretation,
ReferenceRangeLow: refRangeLow,
ReferenceRangeHigh: refRangeHigh,
}
}
@@ -626,6 +882,53 @@ func ExtractDocumentReference(d *DocumentReference, patientFHIRID, ehrURL string
}
}
// ExtractMedicationRequest maps a FHIR MedicationRequest to a models.MedicationRequest ready for upsert.
func ExtractMedicationRequest(m *MedicationRequest, patientFHIRID, ehrURL string) *models.MedicationRequest {
medCoding := firstCoding(m.MedicationCodeableConcept)
var dosageText string
if len(m.DosageInstruction) > 0 {
dosageText = m.DosageInstruction[0].Text
}
return &models.MedicationRequest{
FHIRID: m.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: m.Status,
Intent: m.Intent,
MedCodeText: m.MedicationCodeableConcept.Text,
MedCodeSystem: medCoding.System,
MedCodeCode: medCoding.Code,
AuthoredOn: m.AuthoredOn,
RequesterDisplay: m.Requester.Display,
DosageText: dosageText,
}
}
// ExtractAllergyIntolerance maps a FHIR AllergyIntolerance to a models.AllergyIntolerance ready for upsert.
func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL string) *models.AllergyIntolerance {
codeCoding := firstCoding(a.Code)
clinicalStatus := firstCoding(a.ClinicalStatus)
verificationStatus := firstCoding(a.VerificationStatus)
var category string
if len(a.Category) > 0 {
category = a.Category[0]
}
return &models.AllergyIntolerance{
FHIRID: a.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
ClinicalStatus: clinicalStatus.Code,
VerificationStatus: verificationStatus.Code,
Type: a.Type,
Category: category,
Criticality: a.Criticality,
CodeText: a.Code.Text,
CodeSystem: codeCoding.System,
CodeCode: codeCoding.Code,
RecordedDate: a.RecordedDate,
}
}
// ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference
// (e.g. "Practitioner/123" or "Patient/abc") from the id_token's fhirUser claim.
// Returns an empty string if the claim is missing or invalid.

View File

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

View File

@@ -10,7 +10,8 @@ import (
)
// HandleDashboard renders the stable patient dashboard.
// All clinical data is read from the local database; no live FHIR calls are
// On first load (no prior sync), automatically triggers a sync to populate data.
// All other clinical data is read from the local database; no live FHIR calls are
// made here. Use POST /dashboard/sync to refresh data from the EHR.
//
// GET /dashboard
@@ -26,6 +27,27 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID
// Allow overriding the patient context via a query parameter.
if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" {
patientID = overrideID
}
// Check if this is the first load and trigger auto-sync
latestSync, err := h.store.LatestSync(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err)
}
if latestSync == nil {
// First load — redirect to sync endpoint for auto-sync
syncURL := "/dashboard/sync"
if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" {
syncURL += "?patient_id=" + overrideID
}
http.Redirect(w, r, syncURL, http.StatusSeeOther)
return
}
// Fetch patient demographics from the FHIR server. This is a cheap single
// resource call and keeps the patient card always current.
fhirClient := fhir.NewClient(ehrURL, sess.AccessToken)
@@ -54,11 +76,18 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err)
}
latestSync, err := h.store.LatestSync(patientID, ehrURL)
medications, err := h.store.ListMedicationRequests(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err)
log.Printf("handlers: dashboard ListMedicationRequests Patient/%s: %v", patientID, err)
}
allergies, err := h.store.ListAllergyIntolerances(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListAllergyIntolerances Patient/%s: %v", patientID, err)
}
synced := r.URL.Query().Get("synced") == "true"
h.render(w, "dashboard.html", dashboardData{
Patient: patientUser,
Practitioner: practitionerUser,
@@ -66,8 +95,11 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
Observations: observations,
Conditions: conditions,
DocumentReferences: docRefs,
Medications: medications,
Allergies: allergies,
LatestSync: latestSync,
Session: sess,
Synced: synced,
})
}
@@ -79,8 +111,11 @@ type dashboardData struct {
Observations []models.Observation
Conditions []models.Condition
DocumentReferences []models.DocumentReference
Medications []models.MedicationRequest
Allergies []models.AllergyIntolerance
LatestSync *models.PatientSync
Session *models.Session
Synced bool
}
// handleUnauthorized redirects to root for dashboard requests.

View File

@@ -29,6 +29,7 @@ import (
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
const (
@@ -152,5 +153,29 @@ func TemplateFuncs() template.FuncMap {
}
return s
},
"groupByCategory": func(obs []models.Observation) map[string][]models.Observation {
m := make(map[string][]models.Observation)
for _, o := range obs {
cat := o.Category
if cat == "" {
cat = "other"
}
m[cat] = append(m[cat], o)
}
return m
},
"hasCriticalAllergies": func(allergies []models.AllergyIntolerance) bool {
for _, a := range allergies {
if a.Criticality == "high" {
return true
}
}
return false
},
"last": func(slice interface{}) interface{} {
// Helper function to get the last element of a slice
// Used in template logic
return nil
},
}
}

40
app/handlers/patients.go Normal file
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 (
"log"
"net/http"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
)
// HandleSync performs a live FHIR pull for Observations, Conditions, and
// DocumentReferences for the session's patient, upserts all results into the
// database, records a PatientSync event, then redirects back to GET /dashboard.
// HandleSync performs a live FHIR pull for Observations, Conditions, DocumentReferences,
// MedicationRequests, and AllergyIntolerances for the session's patient, upserts all
// results into the database, records a PatientSync event, then redirects back to
// GET /dashboard?synced=true.
//
// POST /dashboard/sync
// Incremental sync: If a previous sync exists, only fetches resources updated since
// the last sync time (using FHIR _lastUpdated parameter).
//
// GET /dashboard/sync (auto-sync on first dashboard load)
// POST /dashboard/sync (manual sync from dashboard UI)
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
@@ -27,12 +33,29 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID
// Allow overriding the patient context via a query parameter.
if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" {
patientID = overrideID
}
// Determine if this is an incremental sync
latestSync, err := h.store.LatestSync(patientID, ehrURL)
if err != nil {
log.Printf("handlers: sync LatestSync Patient/%s: %v", patientID, err)
}
var sinceTime string
if latestSync != nil {
sinceTime = latestSync.SyncedAt.Format(time.RFC3339)
}
client := fhir.NewClient(ehrURL, sess.AccessToken)
// -----------------------------------------------------------------
// Fetch Observations
// -----------------------------------------------------------------
rawObs, err := client.GetObservations(patientID)
rawObs, err := client.GetObservations(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err)
// Non-fatal; continue with whatever we got.
@@ -51,7 +74,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
// -----------------------------------------------------------------
// Fetch Conditions
// -----------------------------------------------------------------
rawConds, err := client.GetConditions(patientID)
rawConds, err := client.GetConditions(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err)
}
@@ -69,7 +92,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
// -----------------------------------------------------------------
// Fetch DocumentReferences
// -----------------------------------------------------------------
rawDocs, err := client.GetDocumentReferences(patientID)
rawDocs, err := client.GetDocumentReferences(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err)
}
@@ -84,6 +107,42 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
docCount++
}
// -----------------------------------------------------------------
// Fetch MedicationRequests
// -----------------------------------------------------------------
rawMeds, err := client.GetMedicationRequests(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetMedicationRequests for Patient/%s: %v", patientID, err)
}
medCount := 0
for i := range rawMeds {
m := fhir.ExtractMedicationRequest(&rawMeds[i], patientID, ehrURL)
if _, err := h.store.UpsertMedicationRequest(m); err != nil {
log.Printf("handlers: sync UpsertMedicationRequest fhir_id=%s: %v", m.FHIRID, err)
continue
}
medCount++
}
// -----------------------------------------------------------------
// Fetch AllergyIntolerances
// -----------------------------------------------------------------
rawAllergies, err := client.GetAllergyIntolerances(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetAllergyIntolerances for Patient/%s: %v", patientID, err)
}
allergyCount := 0
for i := range rawAllergies {
m := fhir.ExtractAllergyIntolerance(&rawAllergies[i], patientID, ehrURL)
if _, err := h.store.UpsertAllergyIntolerance(m); err != nil {
log.Printf("handlers: sync UpsertAllergyIntolerance fhir_id=%s: %v", m.FHIRID, err)
continue
}
allergyCount++
}
// -----------------------------------------------------------------
// Record the sync event
// -----------------------------------------------------------------
@@ -91,8 +150,12 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err)
}
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d",
patientID, obsCount, condCount, docCount)
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d",
patientID, obsCount, condCount, docCount, medCount, allergyCount)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
dashboardURL := "/dashboard?synced=true"
if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" {
dashboardURL += "&patient_id=" + overrideID
}
http.Redirect(w, r, dashboardURL, http.StatusSeeOther)
}

View File

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

View File

@@ -107,20 +107,23 @@ type UserContextKey struct{}
// Observation is the persisted representation of a FHIR R4 Observation.
// The natural key is (fhir_id, ehr_url).
type Observation struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
Category string `json:"category" db:"category"`
CodeText string `json:"code_text" db:"code_text"`
CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"`
EffectiveDate string `json:"effective_date" db:"effective_date"`
ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"`
ValueUnit string `json:"value_unit" db:"value_unit"`
ValueString string `json:"value_string" db:"value_string"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
Category string `json:"category" db:"category"`
CodeText string `json:"code_text" db:"code_text"`
CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"`
EffectiveDate string `json:"effective_date" db:"effective_date"`
ValueQuantity *float64 `json:"value_quantity" db:"value_quantity"`
ValueUnit string `json:"value_unit" db:"value_unit"`
ValueString string `json:"value_string" db:"value_string"`
Interpretation string `json:"interpretation" db:"interpretation"`
ReferenceRangeLow *float64 `json:"ref_range_low" db:"ref_range_low"`
ReferenceRangeHigh *float64 `json:"ref_range_high" db:"ref_range_high"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// Condition is the persisted representation of a FHIR R4 Condition.
@@ -160,6 +163,41 @@ type DocumentReference struct {
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// MedicationRequest is the persisted representation of a FHIR R4 MedicationRequest.
type MedicationRequest struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
Intent string `json:"intent" db:"intent"`
MedCodeText string `json:"med_code_text" db:"med_code_text"`
MedCodeSystem string `json:"med_code_system" db:"med_code_system"`
MedCodeCode string `json:"med_code_code" db:"med_code_code"`
AuthoredOn string `json:"authored_on" db:"authored_on"`
RequesterDisplay string `json:"requester_display" db:"requester_display"`
DosageText string `json:"dosage_text" db:"dosage_text"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// AllergyIntolerance is the persisted representation of a FHIR R4 AllergyIntolerance.
type AllergyIntolerance struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
ClinicalStatus string `json:"clinical_status" db:"clinical_status"`
VerificationStatus string `json:"verification_status" db:"verification_status"`
Type string `json:"type" db:"type"`
Category string `json:"category" db:"category"`
Criticality string `json:"criticality" db:"criticality"`
CodeText string `json:"code_text" db:"code_text"`
CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"`
RecordedDate string `json:"recorded_date" db:"recorded_date"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// PatientSync records a completed FHIR sync event for a patient.
type PatientSync struct {
ID string `json:"id" db:"id"`

View File

@@ -1,6 +1,7 @@
{{template "base.html" .}}
{{define "nav"}}
<a href="/patients">All Patients</a>
<a href="/">Home</a>
<form action="/logout" method="POST" style="display:inline">
<button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button>
@@ -9,6 +10,19 @@
{{define "content"}}
{{/* ---- Success Flash Message ---- */}}
{{if .Synced}}
<div class="card" style="background-color:#d4edda;border-left:4px solid #28a745;margin-bottom:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<strong>✓ Sync complete</strong>
<span style="font-size:.875rem;color:#555;">Data has been refreshed from the EHR.</span>
</div>
<button onclick="this.parentElement.style.display='none';" style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:#666;">&times;</button>
</div>
</div>
{{end}}
{{/* ---- Practitioner block ---- */}}
{{if .Practitioner}}
<div class="card">
@@ -101,6 +115,25 @@
</div>
</div>
{{/* ---- High-Criticality Allergy Warning Card ---- */}}
{{if hasCriticalAllergies .Allergies}}
<div class="card" style="border-left:4px solid #dc3545;">
<div style="display:flex;align-items:flex-start;gap:12px;">
<span style="font-size:1.5rem;"></span>
<div>
<strong style="color:#dc3545;">High-Criticality Allergies Detected</strong>
<p style="margin-top:4px;margin-bottom:0;font-size:.875rem;color:#555;">
{{range .Allergies}}
{{if eq .Criticality "high"}}
<strong>{{.CodeText}}</strong> ({{.ClinicalStatus}}){{if ne .CodeText (last .Allergies).CodeText}}<br/>{{end}}
{{end}}
{{end}}
</p>
</div>
</div>
</div>
{{end}}
{{/* ---- Clinical Data block ---- */}}
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
@@ -109,14 +142,12 @@
{{if .LatestSync}}
<span class="text-muted" style="font-size:.8rem;">
Last synced: {{formatDateTime .LatestSync.SyncedAt}}
&nbsp;&middot;&nbsp;
{{.LatestSync.ObsCount}} obs &middot; {{.LatestSync.CondCount}} cond &middot; {{.LatestSync.DocCount}} docs
</span>
{{else}}
<span class="text-muted" style="font-size:.8rem;">Never synced</span>
{{end}}
<form action="/dashboard/sync" method="POST" style="display:inline;">
<button class="btn btn-primary" type="submit" style="padding:6px 14px;font-size:.875rem;">
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" style="display:inline;">
<button class="btn btn-primary" type="submit" style="padding:6px 14px;font-size:.875rem;" id="syncBtn" onclick="onSyncClick()">
&#x21bb;&nbsp;Sync with EHR
</button>
</form>
@@ -124,49 +155,75 @@
</div>
<div class="tabs">
<button class="tab-link active" onclick="openTab(event, 'observations')">Observations ({{len .Observations}})</button>
<button class="tab-link active" onclick="openTab(event, 'observations')">Vitals & Labs ({{len .Observations}})</button>
<button class="tab-link" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
<button class="tab-link" onclick="openTab(event, 'medications')">Medications ({{len .Medications}})</button>
<button class="tab-link" onclick="openTab(event, 'allergies')">Allergies ({{len .Allergies}})</button>
<button class="tab-link" onclick="openTab(event, 'notes')">Clinical Notes ({{len .DocumentReferences}})</button>
<button class="tab-link" onclick="openTab(event, 'smart')">SMART Inspector</button>
</div>
{{/* ---- Observations Tab (Grouped by Category) ---- */}}
<div id="observations" class="tab-content" style="display:block;">
{{if .Observations}}
<table class="fhir-table">
<thead>
<tr>
<th>Date</th>
<th>Code</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{range .Observations}}
<tr>
<td>{{orDash .EffectiveDate}}</td>
<td>{{orDash .CodeText}}</td>
<td>
{{if .ValueQuantity}}
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}
{{else if .ValueString}}
{{.ValueString}}
{{else}}
{{end}}
</td>
<td><span class="badge badge-outline">{{.Status}}</span></td>
</tr>
{{end}}
</tbody>
</table>
{{range $cat, $obs := groupByCategory .Observations}}
<div style="margin-bottom:20px;">
<h4 style="margin-bottom:12px;color:#333;">{{titleCase $cat}}</h4>
<table class="fhir-table">
<thead>
<tr>
<th>Date</th>
<th>Code</th>
<th>Value</th>
<th style="width:60px;">Interpretation</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{range $obs}}
<tr>
<td>{{orDash .EffectiveDate}}</td>
<td>{{orDash .CodeText}}</td>
<td>
{{if .ValueQuantity}}
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
<br/><span style="font-size:.8rem;color:#666;">
[{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
</span>
{{end}}
{{else if .ValueString}}
{{.ValueString}}
{{else}}
{{end}}
</td>
<td>
{{if .Interpretation}}
<span class="badge" style="font-size:.75rem;background:#e3f2fd;color:#1976d2;">{{.Interpretation}}</span>
{{else}}
{{end}}
</td>
<td><span class="badge badge-outline">{{.Status}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<p class="text-muted mt-4">No observations found. Use "Sync with EHR" to pull data.</p>
{{end}}
</div>
{{/* ---- Conditions Tab (with filter bar) ---- */}}
<div id="conditions" class="tab-content" style="display:none;">
{{if .Conditions}}
<div style="margin-bottom:12px;display:flex;gap:8px;">
<button onclick="filterConditions('all')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">All</button>
<button onclick="filterConditions('active')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">Active</button>
<button onclick="filterConditions('resolved')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">Resolved</button>
</div>
<table class="fhir-table">
<thead>
<tr>
@@ -178,7 +235,7 @@
</thead>
<tbody>
{{range .Conditions}}
<tr>
<tr class="condition-row" data-status="{{.ClinicalStatus}}">
<td>{{orDash .RecordedDate}}</td>
<td>{{orDash .CodeText}}</td>
<td>{{titleCase .ClinicalStatus}}</td>
@@ -192,6 +249,75 @@
{{end}}
</div>
{{/* ---- Medications Tab ---- */}}
<div id="medications" class="tab-content" style="display:none;">
{{if .Medications}}
<table class="fhir-table">
<thead>
<tr>
<th>Authored On</th>
<th>Medication</th>
<th>Dosage</th>
<th>Status</th>
<th>Requester</th>
</tr>
</thead>
<tbody>
{{range .Medications}}
<tr>
<td>{{orDash .AuthoredOn}}</td>
<td>{{orDash .MedCodeText}}</td>
<td>{{orDash .DosageText}}</td>
<td><span class="badge badge-outline">{{.Status}}</span></td>
<td>{{orDash .RequesterDisplay}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted mt-4">No medications found. Use "Sync with EHR" to pull data.</p>
{{end}}
</div>
{{/* ---- Allergies Tab ---- */}}
<div id="allergies" class="tab-content" style="display:none;">
{{if .Allergies}}
<table class="fhir-table">
<thead>
<tr>
<th>Recorded Date</th>
<th>Allergen</th>
<th>Type</th>
<th>Criticality</th>
<th>Clinical Status</th>
</tr>
</thead>
<tbody>
{{range .Allergies}}
<tr>
<td>{{orDash .RecordedDate}}</td>
<td>{{orDash .CodeText}}</td>
<td>{{titleCase .Type}}</td>
<td>
{{if eq .Criticality "high"}}
<span class="badge" style="background:#dc3545;color:white;">{{.Criticality}}</span>
{{else if eq .Criticality "medium"}}
<span class="badge" style="background:#ffc107;color:#000;">{{.Criticality}}</span>
{{else}}
<span class="badge badge-outline">{{orDash .Criticality}}</span>
{{end}}
</td>
<td>{{titleCase .ClinicalStatus}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted mt-4">No allergies found. Use "Sync with EHR" to pull data.</p>
{{end}}
</div>
{{/* ---- Clinical Notes Tab ---- */}}
<div id="notes" class="tab-content" style="display:none;">
{{if .DocumentReferences}}
<table class="fhir-table">
@@ -228,8 +354,14 @@
<p class="text-muted mt-4">No clinical notes found. Use "Sync with EHR" to pull data.</p>
{{end}}
</div>
</div>
<div id="smart" class="tab-content" style="display:none;">
{{/* ---- SMART Inspector Accordion ---- */}}
<details style="margin-bottom:20px;">
<summary style="cursor:pointer; font-size:.875rem; font-weight:600; color:var(--color-primary); padding:8px 0;">
SMART Inspector
</summary>
<div class="card" style="margin-top:8px;">
<div class="mt-4">
<div class="detail-item mb-4">
<label>FHIR Base URL (ISS)</label>
@@ -251,7 +383,7 @@
{{end}}
</div>
</div>
</div>
</details>
{{/* ---- Raw FHIR resource accordion ---- */}}
{{if .RawPatient}}
@@ -331,5 +463,23 @@ function openTab(evt, tabName) {
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
function filterConditions(status) {
var rows = document.getElementsByClassName("condition-row");
for (var i = 0; i < rows.length; i++) {
var rowStatus = rows[i].getAttribute("data-status");
if (status === "all" || rowStatus === status) {
rows[i].style.display = "";
} else {
rows[i].style.display = "none";
}
}
}
function onSyncClick() {
var btn = document.getElementById("syncBtn");
btn.disabled = true;
btn.textContent = "Syncing\u2026";
}
</script>
{{end}}

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