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

@@ -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.