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

+
+ + + + diff --git a/frontend/src/app/components/glossary-lookup/glossary-lookup.component.scss b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.scss new file mode 100644 index 00000000..911fcd05 --- /dev/null +++ b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.scss @@ -0,0 +1,5 @@ +:host { + max-height:300px; + overflow-y:scroll; + display: inline-block; +} diff --git a/frontend/src/app/components/glossary-lookup/glossary-lookup.component.spec.ts b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.spec.ts new file mode 100644 index 00000000..7b5bba52 --- /dev/null +++ b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GlossaryLookupComponent } from './glossary-lookup.component'; + +describe('GlossaryLookupComponent', () => { + let component: GlossaryLookupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ GlossaryLookupComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GlossaryLookupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/glossary-lookup/glossary-lookup.component.ts b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.ts new file mode 100644 index 00000000..49e170b0 --- /dev/null +++ b/frontend/src/app/components/glossary-lookup/glossary-lookup.component.ts @@ -0,0 +1,36 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {FastenApiService} from '../../services/fasten-api.service'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; + +@Component({ + selector: 'app-glossary-lookup', + templateUrl: './glossary-lookup.component.html', + styleUrls: ['./glossary-lookup.component.scss'] +}) +export class GlossaryLookupComponent implements OnInit { + + @Input() code: string + @Input() codeSystem: string + @Input() snippetLength: number = -1 + + description: SafeHtml = "" + url: string = "" + source: string = "" + loading: boolean = true + + constructor(private fastenApi: FastenApiService, private sanitized: DomSanitizer) { } + + ngOnInit(): void { + this.fastenApi.getGlossarySearchByCode(this.code, this.codeSystem).subscribe(result => { + this.loading = false + console.log(result) + this.url = result?.url + this.source = result?.publisher + this.description = this.sanitized.bypassSecurityTrustHtml(result?.description) + // this.description = result.description + }, error => { + this.loading = false + }) + } + +} diff --git a/frontend/src/app/components/report-labs-observation/report-labs-observation.component.html b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.html index b4a5d939..62b4980d 100644 --- a/frontend/src/app/components/report-labs-observation/report-labs-observation.component.html +++ b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.html @@ -48,12 +48,8 @@
-
-

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

+
+
diff --git a/frontend/src/app/components/report-medical-history-condition/report-medical-history-condition.component.html b/frontend/src/app/components/report-medical-history-condition/report-medical-history-condition.component.html index 78cb1ca6..2ab28365 100644 --- a/frontend/src/app/components/report-medical-history-condition/report-medical-history-condition.component.html +++ b/frontend/src/app/components/report-medical-history-condition/report-medical-history-condition.component.html @@ -32,11 +32,9 @@ -
-

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

+
diff --git a/frontend/src/app/components/shared.module.ts b/frontend/src/app/components/shared.module.ts index f95f9b8c..c614c0c4 100644 --- a/frontend/src/app/components/shared.module.ts +++ b/frontend/src/app/components/shared.module.ts @@ -65,6 +65,7 @@ import { PractitionerComponent } from './fhir/resources/practitioner/practitione import {PipesModule} from '../pipes/pipes.module'; import { NlmTypeaheadComponent } from './nlm-typeahead/nlm-typeahead.component'; import { DocumentReferenceComponent } from './fhir/resources/document-reference/document-reference.component'; +import { GlossaryLookupComponent } from './glossary-lookup/glossary-lookup.component'; @NgModule({ imports: [ @@ -138,6 +139,7 @@ import { DocumentReferenceComponent } from './fhir/resources/document-reference/ PractitionerComponent, NlmTypeaheadComponent, DocumentReferenceComponent, + GlossaryLookupComponent, ], exports: [ ComponentsSidebarComponent, @@ -190,6 +192,7 @@ import { DocumentReferenceComponent } from './fhir/resources/document-reference/ PractitionerComponent, NlmTypeaheadComponent, DocumentReferenceComponent, + GlossaryLookupComponent ] }) diff --git a/frontend/src/app/pages/resource-detail/resource-detail.component.html b/frontend/src/app/pages/resource-detail/resource-detail.component.html index 236bc6bf..4f4029e1 100644 --- a/frontend/src/app/pages/resource-detail/resource-detail.component.html +++ b/frontend/src/app/pages/resource-detail/resource-detail.component.html @@ -8,14 +8,22 @@ {{resource?.source_resource_id}}
- - - - -
-
+ + + +

An error occurred while parsing FHIR resource

+
+ + + + +
+
+
diff --git a/frontend/src/app/pages/resource-detail/resource-detail.component.ts b/frontend/src/app/pages/resource-detail/resource-detail.component.ts index 4ff6c3c8..c2f18bfc 100644 --- a/frontend/src/app/pages/resource-detail/resource-detail.component.ts +++ b/frontend/src/app/pages/resource-detail/resource-detail.component.ts @@ -13,6 +13,8 @@ import {FastenDisplayModel} from '../../../lib/models/fasten/fasten-display-mode }) export class ResourceDetailComponent implements OnInit { loading: boolean = false + debugMode = false; + sourceId: string = "" sourceName: string = "" diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 563315c3..7ba9a39f 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -14,6 +14,7 @@ import {AuthService} from './auth.service'; import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path'; import {environment} from '../../environments/environment'; import {ResourceAssociation} from '../models/fasten/resource_association'; +import {ValueSet} from 'fhir/r4'; @Injectable({ providedIn: 'root' @@ -23,6 +24,24 @@ export class FastenApiService { constructor(private _httpClient: HttpClient, private router: Router, private authService: AuthService) { } + /* + TERMINOLOGY SERVER/GLOSSARY ENDPOINTS + */ + getGlossarySearchByCode(code: string, codeSystem: string): Observable { + + const endpointUrl = new URL(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/glossary/code`); + endpointUrl.searchParams.set('code', code); + endpointUrl.searchParams.set('code_system', codeSystem); + + return this._httpClient.get(endpointUrl.toString()) + .pipe( + map((response: ValueSet) => { + console.log("Glossary RESPONSE", response) + return response + }) + ); + } + /* SECURE ENDPOINTS diff --git a/frontend/src/lib/models/resources/condition-model.ts b/frontend/src/lib/models/resources/condition-model.ts index f42a4f85..167c71fb 100644 --- a/frontend/src/lib/models/resources/condition-model.ts +++ b/frontend/src/lib/models/resources/condition-model.ts @@ -8,6 +8,9 @@ import {FastenOptions} from '../fasten/fasten-options'; export class ConditionModel extends FastenDisplayModel { code_text: string | undefined + code_id: string | undefined + code_system: string | undefined + severity_text: string | undefined has_asserter: boolean | undefined asserter: ReferenceModel | undefined @@ -31,6 +34,8 @@ export class ConditionModel extends FastenDisplayModel { _.get(fhirResource, 'code.coding.0.display') || _.get(fhirResource, 'code.text') || _.get(fhirResource, 'code.coding.0.code'); + this.code_id = _.get(fhirResource, 'code.coding.0.code') + this.code_system = _.get(fhirResource, 'code.coding.0.system') this.severity_text = _.get(fhirResource, 'severity.coding.0.display') || _.get(fhirResource, 'severity.text');