From 441c8ab964105779ad133cb4d8c4e87a73b0c518 Mon Sep 17 00:00:00 2001 From: Jason Kulatunga Date: Sun, 3 Mar 2024 14:02:01 -0800 Subject: [PATCH] when querying vital signs, use vital-signs category to find a list of all codes, which are then used to find the last 3 results for this code. --- .../gorm_repository_query_sql_test.go | 42 ++++++ .../pkg/database/gorm_repository_summary.go | 120 +++++++++++++++--- .../utils/ips/templates/immunizations.gohtml | 2 +- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/backend/pkg/database/gorm_repository_query_sql_test.go b/backend/pkg/database/gorm_repository_query_sql_test.go index 756b8569..e4070950 100644 --- a/backend/pkg/database/gorm_repository_query_sql_test.go +++ b/backend/pkg/database/gorm_repository_query_sql_test.go @@ -706,3 +706,45 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithTokenMultipleMod "test_code", "test_code2", "test_code3", "test_code4", "test_code5", "test_code6", "00000000-0000-0000-0000-000000000000", }) } + +// Section Vital Signs Codes Lookup + +func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_SectionVitalSigns_WithTokenGroupByNoModifier() { + //setup + sqliteRepo := suite.TestRepository.(*GormRepository) + sqliteRepo.GormClient = sqliteRepo.GormClient.Session(&gorm.Session{DryRun: true}) + + //test + authContext := context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username") + + sqlQuery, err := sqliteRepo.sqlQueryResources(authContext, models.QueryResource{ + Select: []string{}, + Where: map[string]interface{}{ + "category": "vital-signs", + }, + From: "Observation", + Aggregations: &models.QueryResourceAggregations{ + GroupBy: &models.QueryResourceAggregation{Field: "code:code"}, + }, + }) + require.NoError(suite.T(), err) + var results []map[string]interface{} + statement := sqlQuery.Find(&results).Statement + sqlString := statement.SQL.String() + sqlParams := statement.Vars + + //assert + require.NoError(suite.T(), err) + require.Equal(suite.T(), + strings.Join([]string{ + "SELECT (codeJson.value ->> '$.code') as label, count(*) as value", + "FROM fhir_observation as fhir, json_each(fhir.category) as categoryJson, json_each(fhir.code) as codeJson", + "WHERE ((categoryJson.value ->> '$.code' = ?)) AND (user_id = ?)", + "GROUP BY (codeJson.value ->> '$.code')", + "ORDER BY count(*) DESC", + }, " "), sqlString) + require.Equal(suite.T(), sqlParams, []interface{}{ + "vital-signs", + "00000000-0000-0000-0000-000000000000", + }) +} diff --git a/backend/pkg/database/gorm_repository_summary.go b/backend/pkg/database/gorm_repository_summary.go index 74df41ef..deac59bb 100644 --- a/backend/pkg/database/gorm_repository_summary.go +++ b/backend/pkg/database/gorm_repository_summary.go @@ -10,6 +10,7 @@ import ( "github.com/fastenhealth/fasten-onprem/backend/pkg/utils/ips" "github.com/fastenhealth/gofhir-models/fhir401" "github.com/google/uuid" + "github.com/samber/lo" "log" "time" ) @@ -39,15 +40,15 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte // 6. Create the IPS Bundle //Step 1. Generate the IPS Section Lists - summarySectionResources, err := gr.GetInternationalPatientSummarySectionResources(ctx) + summarySectionQueryResults, err := gr.getInternationalPatientSummarySectionResources(ctx) if err != nil { return nil, nil, err } //Step 2. Create the Composition Section compositionSections := []fhir401.CompositionSection{} - for sectionType, sectionResources := range summarySectionResources { - compositionSection, err := generateIPSCompositionSection(narrativeEngine, sectionType, sectionResources) + for sectionType, sectionQueryResultsList := range summarySectionQueryResults { + compositionSection, err := generateIPSCompositionSection(narrativeEngine, sectionType, sectionQueryResultsList) if err != nil { return nil, nil, err } @@ -146,14 +147,14 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte // TODO: Add the Patient to the bundle // TODO: Add the Fasten Health Organization to the bundle - // Add all the resources to the bundle - for _, sectionResources := range summarySectionResources { - for _, resource := range sectionResources { - ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{ - Resource: json.RawMessage(resource.GetResourceRaw()), - }) - } - } + // TODO: Add all the resources to the bundle + //for _, sectionResources := range summarySectionResources { + // for _, resource := range sectionResources { + // ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{ + // Resource: json.RawMessage(resource.GetResourceRaw()), + // }) + // } + //} return ipsBundle, ipsComposition, nil } @@ -162,18 +163,18 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte // The IPS bundle will contain a summary of all the data in the system, including a list of all sources, and the main Patient // See: https://github.com/fastenhealth/fasten-onprem/issues/170 // See: https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md -func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx context.Context) (map[pkg.IPSSections][]database.IFhirResourceModel, error) { +func (gr *GormRepository) getInternationalPatientSummarySectionResources(ctx context.Context) (map[pkg.IPSSections][]any, error) { - summarySectionResources := map[pkg.IPSSections][]database.IFhirResourceModel{} + summarySectionResources := map[pkg.IPSSections][]any{} // generate queries for each IPS Section for ndx, _ := range pkg.IPSSectionsList { sectionName := pkg.IPSSectionsList[ndx] //initialize the section - summarySectionResources[sectionName] = []database.IFhirResourceModel{} + summarySectionResources[sectionName] = []any{} - queries, err := generateIPSSectionQueries(sectionName) + queries, err := gr.generateIPSSectionQueries(ctx, sectionName) if err != nil { return nil, err } @@ -184,17 +185,17 @@ func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx con return nil, err } - resultsList := convertUnknownInterfaceToFhirSlice(results) + //resultsList := convertUnknownInterfaceToFhirSlice(results) //TODO: generate resource narrative - summarySectionResources[sectionName] = append(summarySectionResources[sectionName], resultsList...) + summarySectionResources[sectionName] = append(summarySectionResources[sectionName], results) } } return summarySectionResources, nil } -func generateIPSCompositionSection(narrativeEngine *ips.Narrative, sectionType pkg.IPSSections, resources []database.IFhirResourceModel) (*fhir401.CompositionSection, error) { +func generateIPSCompositionSection(narrativeEngine *ips.Narrative, sectionType pkg.IPSSections, queryResultsList []any) (*fhir401.CompositionSection, error) { sectionTitle, sectionCode, err := generateIPSSectionHeaderInfo(sectionType) if err != nil { return nil, err @@ -204,6 +205,11 @@ func generateIPSCompositionSection(narrativeEngine *ips.Narrative, sectionType p Title: §ionTitle, Code: §ionCode, } + + //database.IFhirResourceModel + + resources := flattenQueryResultsToResourcesList(queryResultsList) + if len(resources) == 0 { section.EmptyReason = &fhir401.CodeableConcept{ Text: stringPtr("No data available"), @@ -246,7 +252,7 @@ func generateIPSCompositionSection(narrativeEngine *ips.Narrative, sectionType p // https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md // Generate Resource Queries for each IPS Section -func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResource, error) { +func (gr *GormRepository) generateIPSSectionQueries(ctx context.Context, sectionType pkg.IPSSections) ([]models.QueryResource, error) { queries := []models.QueryResource{} switch sectionType { @@ -306,14 +312,59 @@ func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResou }) break case pkg.IPSSectionsVitalSigns: - //TODO: group by code, sort by date, limit to the most recent 3 - queries = append(queries, models.QueryResource{ + //lets query the database for this user, getting a list of unique codes, associated with this category ('vital-signs'). + //our goal is to retrieve the 3 most recent values for each code. + vitalSignsGrouped, err := gr.QueryResources(ctx, models.QueryResource{ Select: nil, From: "Observation", Where: map[string]interface{}{ "category": "vital-signs", }, + Aggregations: &models.QueryResourceAggregations{ + GroupBy: &models.QueryResourceAggregation{Field: "code:code"}, + }, }) + + if err != nil { + return nil, err + } + + vitalSignsGroupedByCodeList, ok := vitalSignsGrouped.([]map[string]any) + if !ok { + return nil, fmt.Errorf("could not decode vital signs grouped by code") + } + + //known codes related to vital signs: https://www.hl7.org/fhir/R4/valueset-observation-vitalsignresult.html#definition + vitalSignCodes := []string{ + "85353-1", "9279-1", "8867-4", "2708-6", "8310-5", "8302-2", "9843-4", "29463-7", "39156-5", "85354-9", "8480-6", "8462-4", "8478-0", + } + + for ndx, _ := range vitalSignsGroupedByCodeList { + //now that we have a list of codes that are tagged as vital-signs. + if labelValue, labelValueOk := vitalSignsGroupedByCodeList[ndx]["label"]; labelValueOk { + if labelValueStr, labeValueStrOk := labelValue.(*interface{}); labeValueStrOk { + vitalSignCodes = append(vitalSignCodes, (*labelValueStr).(string)) + } else { + gr.Logger.Warnf("could not cast vital-sign codes to string") + } + } else { + gr.Logger.Warnf("could not retrieve vital-sign group-by clause label value") + } + } + vitalSignCodes = lo.Uniq(vitalSignCodes) + + limit := 3 + //group by code, sort by date, limit to the most recent 3 + for ndx, _ := range vitalSignCodes { + queries = append(queries, models.QueryResource{ + Select: nil, + From: "Observation", + Where: map[string]interface{}{ + "code": vitalSignCodes[ndx], + }, + Limit: &limit, + }) + } break case pkg.IPSSectionsSocialHistory: queries = append(queries, models.QueryResource{ @@ -614,11 +665,38 @@ func convertUnknownInterfaceToFhirSlice(unknown interface{}) []database.IFhirRes for ndx, _ := range fhirSlice { results = append(results, &fhirSlice[ndx]) } + default: + log.Panicf("could not detect type for query results fhir resource list: %v", fhirSlice) } return results } +// query results may be a list of database.IFhirResourceModel or a map[string][]database.IFhirResourceModel (if we're using aggregations/grouping) +func flattenQueryResultsToResourcesList(queryResultsList []any) []database.IFhirResourceModel { + resources := []database.IFhirResourceModel{} + + for ndx, _ := range queryResultsList { + queryResults := queryResultsList[ndx] + switch queryResultsTyped := queryResults.(type) { + case []map[string]any: + //aggregated resources + for andx, _ := range queryResultsTyped { + queryResultsGrouped := queryResultsTyped[andx] + resources = append(resources, convertUnknownInterfaceToFhirSlice(queryResultsGrouped)...) + } + + case interface{}: + //list of resources + resources = append(resources, convertUnknownInterfaceToFhirSlice(queryResultsTyped)...) + default: + log.Panicf("Unknown Resource Structure: %v", queryResultsTyped) + } + } + + return resources +} + func generateIPSSectionNarrative(sectionType pkg.IPSSections, resources []models.ResourceBase) string { return "" } diff --git a/backend/pkg/utils/ips/templates/immunizations.gohtml b/backend/pkg/utils/ips/templates/immunizations.gohtml index 9161f09a..748ad730 100644 --- a/backend/pkg/utils/ips/templates/immunizations.gohtml +++ b/backend/pkg/utils/ips/templates/immunizations.gohtml @@ -30,7 +30,7 @@ Date: Immunization.occurrenceDateTime || Immunization.occurrenceString {{pluckList "text" ($entry.VaccineCode | parseList) | uniq | join "," }} {{/* Immunization */}} {{pluckList "code" ($entry.Status | parseList) | uniq | join "," }} {{/* Status */}} Dose Number {{/* Comments - TODO: use FHIRPath */}} - Manufacturer {{/* Manufacturer - TODO: use FHIRPath */}} + {{pluckList "display" ($entry.Manufacturer | parseList) | uniq | join "," }} {{/* Manufacturer */}} {{$entry.LotNumber}} {{/* Lot Number */}} Comments {{$entry.Date | date "2006-01-02"}} {{/* Performed Date */}}