diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index 49baaf32..cf98b2d0 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -34,6 +34,9 @@ type DatabaseRepository interface { GetSourceSummary(context.Context, string) (*models.SourceSummary, error) GetSources(context.Context) ([]models.SourceCredential, error) - //used by Client + CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error + GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error) + + //used by fasten-sources Clients UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error) } diff --git a/backend/pkg/database/sqlite_repository.go b/backend/pkg/database/sqlite_repository.go index 1899df75..84e3e7d0 100644 --- a/backend/pkg/database/sqlite_repository.go +++ b/backend/pkg/database/sqlite_repository.go @@ -57,14 +57,14 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) } globalLogger.Infof("Successfully connected to fasten sqlite db: %s\n", appConfig.GetString("database.location")) - deviceRepo := SqliteRepository{ + fastenRepo := SqliteRepository{ AppConfig: appConfig, Logger: globalLogger, GormClient: database, } //TODO: automigrate for now - err = deviceRepo.Migrate() + err = fastenRepo.Migrate() if err != nil { return nil, err } @@ -76,7 +76,7 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) return nil, fmt.Errorf("Failed to create admin user! - %v", err) } - return &deviceRepo, nil + return &fastenRepo, nil } type SqliteRepository struct { @@ -91,6 +91,7 @@ func (sr *SqliteRepository) Migrate() error { &models.User{}, &models.SourceCredential{}, &models.ResourceFhir{}, + &models.Glossary{}, ) if err != nil { return fmt.Errorf("Failed to automigrate! - %v", err) @@ -140,6 +141,24 @@ func (sr *SqliteRepository) GetCurrentUser(ctx context.Context) (*models.User, e return ¤tUser, nil } +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Glossary +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (sr *SqliteRepository) CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error { + record := sr.GormClient.Create(glossaryEntry) + if record.Error != nil { + return record.Error + } + return nil +} + +func (sr *SqliteRepository) GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error) { + var foundGlossaryEntry models.Glossary + result := sr.GormClient.Where(models.Glossary{Code: code, CodeSystem: codeSystem}).First(&foundGlossaryEntry) + return &foundGlossaryEntry, result.Error +} + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Summary //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/backend/pkg/models/glossary.go b/backend/pkg/models/glossary.go new file mode 100644 index 00000000..75f15d97 --- /dev/null +++ b/backend/pkg/models/glossary.go @@ -0,0 +1,14 @@ +package models + +// Glossary contains patient friendly terms for medical concepts +// Can be retrieved by Code and CodeSystem +// Structured similar to ValueSet https://hl7.org/fhir/valueset.html +type Glossary struct { + ModelBase + Code string `json:"code" gorm:"uniqueIndex:idx_glossary_term"` + CodeSystem string `json:"code_system" gorm:"uniqueIndex:idx_glossary_term"` + Publisher string `json:"publisher"` + Title string `json:"title"` + Url string `json:"url"` + Description string `json:"description"` +} diff --git a/backend/pkg/models/medlineplus_connect_results.go b/backend/pkg/models/medlineplus_connect_results.go new file mode 100644 index 00000000..08bcabb4 --- /dev/null +++ b/backend/pkg/models/medlineplus_connect_results.go @@ -0,0 +1,59 @@ +package models + +import "time" + +type MedlinePlusConnectResults struct { + Feed struct { + Base string `json:"base"` + Lang string `json:"lang"` + Xsi string `json:"xsi"` + Title struct { + Type string `json:"type"` + Value string `json:"_value"` + } `json:"title"` + Updated struct { + Value time.Time `json:"_value"` + } `json:"updated"` + ID struct { + Value string `json:"_value"` + } `json:"id"` + Author struct { + Name struct { + Value string `json:"_value"` + } `json:"name"` + URI struct { + Value string `json:"_value"` + } `json:"uri"` + } `json:"author"` + Subtitle struct { + Type string `json:"type"` + Value string `json:"_value"` + } `json:"subtitle"` + Category []struct { + Scheme string `json:"scheme"` + Term string `json:"term"` + } `json:"category"` + Entry []MedlinePlusConnectResultEntry `json:"entry"` + } `json:"feed"` +} + +type MedlinePlusConnectResultEntry struct { + Title struct { + Value string `json:"_value"` + Type string `json:"type"` + } `json:"title"` + Link []struct { + Href string `json:"href"` + Rel string `json:"rel"` + } `json:"link"` + ID struct { + Value string `json:"_value"` + } `json:"id"` + Summary struct { + Type string `json:"type"` + Value string `json:"_value"` + } `json:"summary"` + Updated struct { + Value time.Time `json:"_value"` + } `json:"updated"` +} diff --git a/backend/pkg/web/handler/glossary.go b/backend/pkg/web/handler/glossary.go new file mode 100644 index 00000000..f41b0ac4 --- /dev/null +++ b/backend/pkg/web/handler/glossary.go @@ -0,0 +1,158 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" + "github.com/fastenhealth/gofhir-models/fhir401" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +func FindCodeSystem(codeSystem string) (string, error) { + log.Printf("codeSystem: %s", codeSystem) + if strings.HasPrefix(codeSystem, "2.16.840.1.113883.6.") { + return codeSystem, nil + } + + //https://terminology.hl7.org/external_terminologies.html + codeSystemIds := map[string]string{ + "http://hl7.org/fhir/sid/icd-10-cm": "2.16.840.1.113883.6.90", + "http://hl7.org/fhir/sid/icd-10": "2.16.840.1.113883.6.90", + "http://terminology.hl7.org/CodeSystem/icd9cm": "2.16.840.1.113883.6.103", + "http://snomed.info/sct": "2.16.840.1.113883.6.96", + "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88", + "http://hl7.org/fhir/sid/ndc": "2.16.840.1.113883.6.69", + "http://loinc.org": "2.16.840.1.113883.6.1", + "http://www.ama-assn.org/go/cpt": "2.16.840.1.113883.6.12", + } + + if codeSystemId, ok := codeSystemIds[codeSystem]; ok { + return codeSystemId, nil + } else { + return "", fmt.Errorf("Code System not found") + } + +} + +// https://medlineplus.gov/medlineplus-connect/web-service/ +// NOTE: max requests is 100/min +func GlossarySearchByCode(c *gin.Context) { + logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) + databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) + + codeSystemId, err := FindCodeSystem(c.Query("code_system")) + if err != nil { + logger.Error(err) + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + if c.Query("code") == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "code is required", + }) + return + } + + //Check if the code is in the DB cache + foundGlossaryEntry, err := databaseRepo.GetGlossaryEntry(c, c.Query("code"), codeSystemId) + if err == nil { + //found in DB cache + logger.Debugf("Found code (%s %s) in DB cache", c.Query("code"), codeSystemId) + dateStr := foundGlossaryEntry.UpdatedAt.Format(time.RFC3339) + valueSet := fhir401.ValueSet{ + Title: &foundGlossaryEntry.Title, + Url: &foundGlossaryEntry.Url, + Description: &foundGlossaryEntry.Description, + Date: &dateStr, + Publisher: &foundGlossaryEntry.Publisher, + } + + c.JSON(http.StatusOK, valueSet) + return + } + + // Define the URL for the MedlinePlus Connect Web Service API + medlinePlusConnectEndpoint := "https://connect.medlineplus.gov/service" + + // Define the query parameters + params := url.Values{ + "informationRecipient.languageCode.c": []string{"en"}, + "knowledgeResponseType": []string{"application/json"}, + "mainSearchCriteria.v.c": []string{c.Query("code")}, + "mainSearchCriteria.v.cs": []string{codeSystemId}, + } + + // Send the HTTP GET request to the API and retrieve the response + //TODO: when using IPV6 to communicate with MedlinePlus, we're getting timeouts. Force IPV4 + var ( + zeroDialer net.Dialer + httpClient = &http.Client{ + Timeout: 10 * time.Second, + } + ) + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return zeroDialer.DialContext(ctx, "tcp4", addr) + } + httpClient.Transport = transport + resp, err := httpClient.Get(medlinePlusConnectEndpoint + "?" + params.Encode()) + if err != nil { + fmt.Println("Error sending request:", err) + return + } + defer resp.Body.Close() + + // Parse the JSON response into a struct + var response models.MedlinePlusConnectResults + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + fmt.Println("Error parsing response:", err) + return + } + + if len(response.Feed.Entry) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "No results found"}) + return + } else { + foundEntry := response.Feed.Entry[0] + + dateStr := foundEntry.Updated.Value.Format(time.RFC3339) + valueSet := fhir401.ValueSet{ + Title: &foundEntry.Title.Value, + Url: &foundEntry.Link[0].Href, + Description: &foundEntry.Summary.Value, + Date: &dateStr, + Publisher: &response.Feed.Author.Name.Value, + } + + //store in DB cache (ignore errors) + databaseRepo.CreateGlossaryEntry(c, &models.Glossary{ + ModelBase: models.ModelBase{ + CreatedAt: foundEntry.Updated.Value, + UpdatedAt: foundEntry.Updated.Value, + }, + Code: c.Query("code"), + CodeSystem: codeSystemId, + Publisher: response.Feed.Author.Name.Value, + Title: foundEntry.Title.Value, + Url: foundEntry.Link[0].Href, + Description: foundEntry.Summary.Value, + }) + + c.JSON(http.StatusOK, valueSet) + } +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 1bdbb287..36d5cc90 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -46,6 +46,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { //r.Any("/database/*proxyPath", handler.CouchDBProxy) //r.GET("/cors/*proxyPath", handler.CORSProxy) //r.OPTIONS("/cors/*proxyPath", handler.CORSProxy) + api.GET("/glossary/code", handler.GlossarySearchByCode) secure := api.Group("/secure").Use(middleware.RequireAuth()) { @@ -61,6 +62,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { secure.GET("/resource/graph", handler.GetResourceFhirGraph) secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir) secure.POST("/resource/composition", handler.CreateResourceComposition) + } if ae.Config.GetBool("web.allow_unsafe_endpoints") { diff --git a/frontend/src/app/components/glossary-lookup/glossary-lookup.component.html b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.html new file mode 100644 index 00000000..77cb149d --- /dev/null +++ b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.html @@ -0,0 +1,8 @@ +
Source: {{source}}
+- - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -
+Initial Presentation
-- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. -
+Definition
+
- An error occurred while parsing FHIR resource
+
+