diff --git a/backend/pkg/constants.go b/backend/pkg/constants.go index ce565f2a..f2a81cea 100644 --- a/backend/pkg/constants.go +++ b/backend/pkg/constants.go @@ -1,5 +1,7 @@ package pkg +type ResourceGraphType string + const ( ContextKeyTypeConfig string = "CONFIG" ContextKeyTypeDatabase string = "REPOSITORY" @@ -9,4 +11,9 @@ const ( ContextKeyTypeAuthToken string = "AUTH_TOKEN" FhirResourceTypeComposition string = "Composition" + + ResourceGraphTypeMedicalHistory ResourceGraphType = "MedicalHistory" + ResourceGraphTypeAddressBook ResourceGraphType = "AddressBook" + ResourceGraphTypeMedications ResourceGraphType = "Medications" + ResourceGraphTypeBillingReport ResourceGraphType = "BillingReport" ) diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index cf98b2d0..aae132e5 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -3,6 +3,7 @@ package database import ( "context" sourcePkg "github.com/fastenhealth/fasten-sources/clients/models" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" ) @@ -24,7 +25,7 @@ type DatabaseRepository interface { GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error - GetFlattenedResourceGraph(ctx context.Context) ([]*models.ResourceFhir, []*models.ResourceFhir, error) + GetFlattenedResourceGraph(ctx context.Context, graphType pkg.ResourceGraphType) (map[string][]*models.ResourceFhir, error) AddResourceComposition(ctx context.Context, compositionTitle string, resources []*models.ResourceFhir) error //UpsertProfile(context.Context, *models.Profile) error //UpsertOrganziation(context.Context, *models.Organization) error diff --git a/backend/pkg/database/sqlite_repository.go b/backend/pkg/database/sqlite_repository.go index cba11bf4..9afb7248 100644 --- a/backend/pkg/database/sqlite_repository.go +++ b/backend/pkg/database/sqlite_repository.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/dominikbraun/graph" sourceModel "github.com/fastenhealth/fasten-sources/clients/models" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config" @@ -14,7 +13,6 @@ import ( "github.com/glebarez/sqlite" "github.com/google/uuid" "github.com/sirupsen/logrus" - "golang.org/x/exp/slices" "gorm.io/datatypes" "gorm.io/gorm" "net/url" @@ -234,7 +232,7 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent //create associations //note: we create the association in the related_resources table **before** the model actually exists. - + //note: these associations are not reciprocal, (i.e. if Procedure references Location, Location may not reference Procedure) if rawResource.ReferencedResources != nil && len(rawResource.ReferencedResources) > 0 { for _, referencedResource := range rawResource.ReferencedResources { parts := strings.Split(referencedResource, "/") @@ -250,7 +248,15 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent }, RelatedResourceFhir: nil, } - err := sr.AddReciprocalResourceAssociations(ctx, &source, wrappedResourceModel, &source, relatedResource) + err := sr.AddResourceAssociation( + ctx, + &source, + wrappedResourceModel.SourceResourceType, + wrappedResourceModel.SourceResourceID, + &source, + relatedResource.SourceResourceType, + relatedResource.SourceResourceID, + ) if err != nil { sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err) } @@ -417,283 +423,31 @@ func (sr *SqliteRepository) GetPatientForSources(ctx context.Context) ([]models. return wrappedResourceModels, results.Error } -// Retrieve a list of all fhir resources (vertex), and a list of all associations (edge) -// Generate a graph -// return list of root nodes, and their flattened related resources. -func (sr *SqliteRepository) GetFlattenedResourceGraph(ctx context.Context) ([]*models.ResourceFhir, []*models.ResourceFhir, error) { - currentUser, currentUserErr := sr.GetCurrentUser(ctx) - if currentUserErr != nil { - return nil, nil, currentUserErr - } - - // Get list of all resources - wrappedResourceModels, err := sr.ListResources(ctx, models.ListResourceQueryOptions{}) - if err != nil { - return nil, nil, err - } - - // Get list of all relationships - var relatedResourceRelationships []models.RelatedResource - - // SELECT * FROM related_resources WHERE user_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9"; - result := sr.GormClient.WithContext(ctx). - Table("related_resources"). - Where(models.RelatedResource{ - ResourceFhirUserID: currentUser.ID, - }). - Scan(&relatedResourceRelationships) - if result.Error != nil { - return nil, nil, result.Error - } - - //Generate Graph - // TODO optimization: eventually cache the graph in a database/storage, and update when new resources are added. - g := graph.New(resourceVertexId, graph.Directed(), graph.Acyclic(), graph.Rooted()) - - //add vertices to the graph (must be done first) - for ndx, _ := range wrappedResourceModels { - err = g.AddVertex( - &wrappedResourceModels[ndx], - ) - if err != nil { - return nil, nil, fmt.Errorf("an error occurred while adding vertex: %v", err) - } - } - - //add edges to graph - for _, relationship := range relatedResourceRelationships { - err = g.AddEdge( - resourceKeysVertexId(relationship.ResourceFhirSourceID.String(), relationship.ResourceFhirSourceResourceType, relationship.ResourceFhirSourceResourceID), - resourceKeysVertexId(relationship.RelatedResourceFhirSourceID.String(), relationship.RelatedResourceFhirSourceResourceType, relationship.RelatedResourceFhirSourceResourceID), - ) - if err != nil { - //this may occur because vertices may not exist - sr.Logger.Warnf("ignoring, an error occurred while adding edge: %v", err) - } - } - - //// simplify graph if possible. - //graph.TransitiveReduction(g) - - // AdjacencyMap computes and returns an adjacency map containing all vertices in the graph. - // - // There is an entry for each vertex, and each of those entries is another map whose keys are - // the hash values of the adjacent vertices. The value is an Edge instance that stores the - // source and target hash values (these are the same as the map keys) as well as edge metadata. - // map[string]map[string]Edge[string]{ - // "A": map[string]Edge[string]{ - // "B": {Source: "A", Target: "B"} - // "C": {Source: "A", Target: "C"} - // } - // } - adjacencyMap, err := g.AdjacencyMap() - if err != nil { - return nil, nil, fmt.Errorf("error while generating AdjacencyMap: %v", err) - } - - // For a directed graph, PredecessorMap is the complement of AdjacencyMap. This is because in a directed graph, only - // vertices joined by an outgoing edge are considered adjacent to the current vertex, whereas - // predecessors are the vertices joined by an ingoing edge. - // ie. "empty" verticies in this map are "root" nodes. - predecessorMap, err := g.PredecessorMap() - if err != nil { - return nil, nil, fmt.Errorf("error while generating PredecessorMap: %v", err) - } - - // Doing this in one massive function, because passing graph by reference is difficult due to generics. - - // Step 1: use predecessorMap to find all "root" encounters and conditions. store those nodes in their respective lists. - encounterList := []*models.ResourceFhir{} - conditionList := []*models.ResourceFhir{} - for vertexId, val := range predecessorMap { - - if len(val) != 0 { - //skip any nodes/verticies/resources that are not "root" - continue - } - - resource, err := g.Vertex(vertexId) - if err != nil { - //could not find this vertex in graph, ignoring - continue - } - - if strings.ToLower(resource.SourceResourceType) == "condition" || strings.ToLower(resource.SourceResourceType) == strings.ToLower(pkg.FhirResourceTypeComposition) { - conditionList = append(conditionList, resource) - } else if strings.ToLower(resource.SourceResourceType) == "encounter" { - encounterList = append(encounterList, resource) - } - } - - // Step 2: define a function. When given a resource, should find all related resources, flatten the heirarchy and set the RelatedResourceFhir list - flattenRelatedResourcesFn := func(resource *models.ResourceFhir) { - // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list - vertexId := resourceVertexId(resource) - sr.Logger.Debugf("populating resource: %s", vertexId) - - resource.RelatedResourceFhir = []*models.ResourceFhir{} - - //get all the resources associated with this node - graph.DFS(g, vertexId, func(relatedVertexId string) bool { - relatedResourceFhir, _ := g.Vertex(relatedVertexId) - //skip the current resource if it's referenced in this list. - //also skip the current resource if its a Binary resource (which is a special case) - if vertexId != resourceVertexId(relatedResourceFhir) && relatedResourceFhir.SourceResourceType != "Binary" { - resource.RelatedResourceFhir = append(resource.RelatedResourceFhir, relatedResourceFhir) - } - return false - }) - } - - // Step 3: populate related resources for each encounter, flattened - for ndx, _ := range encounterList { - // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list - flattenRelatedResourcesFn(encounterList[ndx]) - - //sort all related resources (by date, desc) - encounterList[ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(encounterList[ndx].RelatedResourceFhir) - - } - - // Step 4: find all encounters referenced by the root conditions, populate them, then add them to the condition as RelatedResourceFhir - for ndx, _ := range conditionList { - // this is a "root" condition, - - conditionList[ndx].RelatedResourceFhir = []*models.ResourceFhir{} - vertexId := resourceVertexId(conditionList[ndx]) - for relatedVertexId, _ := range adjacencyMap[vertexId] { - relatedResourceFhir, _ := g.Vertex(relatedVertexId) - flattenRelatedResourcesFn(relatedResourceFhir) - conditionList[ndx].RelatedResourceFhir = append(conditionList[ndx].RelatedResourceFhir, relatedResourceFhir) - } - - //sort all related resources (by date, desc) - conditionList[ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(conditionList[ndx].RelatedResourceFhir) - } - - conditionList = utils.SortResourcePtrListByDate(conditionList) - encounterList = utils.SortResourcePtrListByDate(encounterList) - return conditionList, encounterList, nil -} - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Resource Associations //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -//edges are always "strongly connected", however "source" nodes (roots, like Condition or Encounter) are only one way. -//add an edge from every resource to its related resource. Keep in mind that FHIR resources may not contain reciprocal edges, so we ensure the graph is rooted by flipping any -//related resources that are "Condition" or "Encounter" -func (sr *SqliteRepository) AddReciprocalResourceAssociations(ctx context.Context, source *models.SourceCredential, resource *models.ResourceFhir, relatedSource *models.SourceCredential, relatedResource *models.ResourceFhir) error { +func (sr *SqliteRepository) VerifyAssociationPermission(ctx context.Context, sourceUserID uuid.UUID, relatedSourceUserID uuid.UUID) error { //ensure that the sources are "owned" by the same user currentUser, currentUserErr := sr.GetCurrentUser(ctx) if currentUserErr != nil { return currentUserErr } - if source.UserID != relatedSource.UserID { + if sourceUserID != relatedSourceUserID { return fmt.Errorf("user id's must match when adding associations") - } else if source.UserID != currentUser.ID { + } else if sourceUserID != currentUser.ID { return fmt.Errorf("user id's must match current user") } - //manually create association(s) (we've tried to create using Association.Append, and it doesnt work for some reason. - - if isResourceGraphSource(resource) && isResourceGraphSource(relatedResource) { - //handle the case where both resources are "sources" (Condition or Encounter) - if strings.ToLower(resource.SourceResourceType) == "condition" { - //condition is always the source - if err := sr.AddResourceAssociation( - ctx, - source, - resource.SourceResourceType, - resource.SourceResourceID, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - } else { - - if err := sr.AddResourceAssociation( - ctx, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - source, - resource.SourceResourceType, - resource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - } - - } else if isResourceGraphSource(resource) || isResourceGraphSink(relatedResource) { - //resource is a Source, or the relatedResource is a sink, create a "resource" => "relatedResource" edge - - if err := sr.AddResourceAssociation( - ctx, - source, - resource.SourceResourceType, - resource.SourceResourceID, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - - } else if isResourceGraphSource(relatedResource) || isResourceGraphSink(resource) { - //relatedResource is a Source, or the resource is a sink, create a "relatedResource" => "resource" edge - - if err := sr.AddResourceAssociation( - ctx, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - source, - resource.SourceResourceType, - resource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - - } else { - //this is a regular pair of resources, create reciprocal edges - - if err := sr.AddResourceAssociation( - ctx, - source, - resource.SourceResourceType, - resource.SourceResourceID, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - - if err := sr.AddResourceAssociation( - ctx, - source, - relatedResource.SourceResourceType, - relatedResource.SourceResourceID, - source, - resource.SourceResourceType, - resource.SourceResourceID, - ); err != nil { - //ignoring errors, could be due to duplicate edges - sr.Logger.Warnf("an error occurred while creating resource association: %v", err) - } - } return nil } func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error { + //ensure that the sources are "owned" by the same user + err := sr.VerifyAssociationPermission(ctx, source.UserID, relatedSource.UserID) + if err != nil { + return err + } + return sr.GormClient.WithContext(ctx).Table("related_resources").Create(map[string]interface{}{ "resource_fhir_user_id": source.UserID, "resource_fhir_source_id": source.ID, @@ -707,15 +461,10 @@ func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source * } func (sr *SqliteRepository) RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error { - currentUser, currentUserErr := sr.GetCurrentUser(ctx) - if currentUserErr != nil { - return currentUserErr - } - - if source.UserID != relatedSource.UserID { - return fmt.Errorf("user id's must match when adding associations") - } else if source.UserID != currentUser.ID { - return fmt.Errorf("user id's must match current user") + //ensure that the sources are "owned" by the same user + err := sr.VerifyAssociationPermission(ctx, source.UserID, relatedSource.UserID) + if err != nil { + return err } //manually create association (we've tried to create using Association.Append, and it doesnt work for some reason. @@ -1011,23 +760,3 @@ func sqlitePragmaString(pragmas map[string]string) string { } return "" } - -//source resource types are resources that are at the root of the graph, nothing may reference them directly -func isResourceGraphSource(resource *models.ResourceFhir) bool { - sourceResourceTypes := []string{"condition", "encounter"} - return slices.Contains(sourceResourceTypes, strings.ToLower(resource.SourceResourceType)) // true -} - -//sink resource types are the leaves of the graph, they must not reference anything else. (only be referenced) -func isResourceGraphSink(resource *models.ResourceFhir) bool { - sinkResourceTypes := []string{"location", "binary", "device", "organization", "practitioner", "medication", "patient"} - return slices.Contains(sinkResourceTypes, strings.ToLower(resource.SourceResourceType)) // true -} - -// helper function for GetResourceGraph, creating a "hash" for the resource -func resourceVertexId(resource *models.ResourceFhir) string { - return resourceKeysVertexId(resource.SourceID.String(), resource.SourceResourceType, resource.SourceResourceID) -} -func resourceKeysVertexId(sourceId string, resourceType string, resourceId string) string { - return strings.ToLower(fmt.Sprintf("%s/%s/%s", sourceId, resourceType, resourceId)) -} diff --git a/backend/pkg/database/sqlite_repository_graph.go b/backend/pkg/database/sqlite_repository_graph.go new file mode 100644 index 00000000..3c24b535 --- /dev/null +++ b/backend/pkg/database/sqlite_repository_graph.go @@ -0,0 +1,359 @@ +package database + +import ( + "context" + "fmt" + "github.com/dominikbraun/graph" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/utils" + "golang.org/x/exp/slices" + "log" + "strings" +) + +// Retrieve a list of all fhir resources (vertex), and a list of all associations (edge) +// Generate a graph +// return list of root nodes, and their flattened related resources. +func (sr *SqliteRepository) GetFlattenedResourceGraph(ctx context.Context, graphType pkg.ResourceGraphType) (map[string][]*models.ResourceFhir, error) { + currentUser, currentUserErr := sr.GetCurrentUser(ctx) + if currentUserErr != nil { + return nil, currentUserErr + } + + // Get list of all resources + wrappedResourceModels, err := sr.ListResources(ctx, models.ListResourceQueryOptions{}) + if err != nil { + return nil, err + } + + // Get list of all (non-reciprocal) relationships + var relatedResourceRelationships []models.RelatedResource + + // SELECT * FROM related_resources WHERE user_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9"; + result := sr.GormClient.WithContext(ctx). + Table("related_resources"). + Where(models.RelatedResource{ + ResourceFhirUserID: currentUser.ID, + }). + Scan(&relatedResourceRelationships) + if result.Error != nil { + return nil, result.Error + } + + //Generate Graph + // TODO optimization: eventually cache the graph in a database/storage, and update when new resources are added. + g := graph.New(resourceVertexId, graph.Directed(), graph.Acyclic(), graph.Rooted()) + + //add vertices to the graph (must be done first) + for ndx, _ := range wrappedResourceModels { + err = g.AddVertex( + &wrappedResourceModels[ndx], + ) + if err != nil { + return nil, fmt.Errorf("an error occurred while adding vertex: %v", err) + } + } + + //add recriprocial relationships (depending on the graph type) + relatedResourceRelationships = sr.PopulateGraphTypeReciprocalRelationships(graphType, relatedResourceRelationships) + + //add edges to graph + for _, relationship := range relatedResourceRelationships { + + err = g.AddEdge( + resourceKeysVertexId(relationship.ResourceFhirSourceID.String(), relationship.ResourceFhirSourceResourceType, relationship.ResourceFhirSourceResourceID), + resourceKeysVertexId(relationship.RelatedResourceFhirSourceID.String(), relationship.RelatedResourceFhirSourceResourceType, relationship.RelatedResourceFhirSourceResourceID), + ) + + if err != nil { + //this may occur because vertices may not exist + sr.Logger.Warnf("ignoring, an error occurred while adding edge: %v", err) + } + } + + //// simplify graph if possible. + //graph.TransitiveReduction(g) + + // AdjacencyMap computes and returns an adjacency map containing all vertices in the graph. + // + // There is an entry for each vertex, and each of those entries is another map whose keys are + // the hash values of the adjacent vertices. The value is an Edge instance that stores the + // source and target hash values (these are the same as the map keys) as well as edge metadata. + // map[string]map[string]Edge[string]{ + // "A": map[string]Edge[string]{ + // "B": {Source: "A", Target: "B"} + // "C": {Source: "A", Target: "C"} + // } + // } + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return nil, fmt.Errorf("error while generating AdjacencyMap: %v", err) + } + + // For a directed graph, PredecessorMap is the complement of AdjacencyMap. This is because in a directed graph, only + // vertices joined by an outgoing edge are considered adjacent to the current vertex, whereas + // predecessors are the vertices joined by an ingoing edge. + // ie. "empty" verticies in this map are "root" nodes. + predecessorMap, err := g.PredecessorMap() + if err != nil { + return nil, fmt.Errorf("error while generating PredecessorMap: %v", err) + } + + // Doing this in one massive function, because passing graph by reference is difficult due to generics. + + // Step 1: use predecessorMap to find all "root" resources (eg. MedicalHistory - encounters and conditions). store those nodes in their respective lists. + resourceListDictionary := map[string][]*models.ResourceFhir{} + sources, _, sourceFlattenLevel := getSourcesAndSinksForGraphType(graphType) + + for vertexId, val := range predecessorMap { + + if len(val) != 0 { + //skip any nodes/verticies/resources that are not "root" + continue + } + + resource, err := g.Vertex(vertexId) + if err != nil { + //could not find this vertex in graph, ignoring + log.Printf("could not find vertex in graph: %v", err) + continue + } + + //check if this "root" node (which has no predecessors) is a valid source type + foundSourceType := "" + foundSourceLevel := -1 + for ndx, sourceResourceTypes := range sources { + log.Printf("testing resourceType: %s", resource.SourceResourceType) + + if slices.Contains(sourceResourceTypes, strings.ToLower(resource.SourceResourceType)) { + foundSourceType = resource.SourceResourceType + foundSourceLevel = ndx + break + } + } + + if foundSourceLevel == -1 { + continue //skip this resource, it is not a valid source type + } + + if _, ok := resourceListDictionary[foundSourceType]; !ok { + resourceListDictionary[foundSourceType] = []*models.ResourceFhir{} + } + + resourceListDictionary[foundSourceType] = append(resourceListDictionary[foundSourceType], resource) + } + + // Step 2: define a function. When given a resource, should find all related resources, flatten the heirarchy and set the RelatedResourceFhir list + flattenRelatedResourcesFn := func(resource *models.ResourceFhir) { + // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list + vertexId := resourceVertexId(resource) + sr.Logger.Debugf("populating resource: %s", vertexId) + + resource.RelatedResourceFhir = []*models.ResourceFhir{} + + //get all the resources associated with this node + graph.DFS(g, vertexId, func(relatedVertexId string) bool { + relatedResourceFhir, _ := g.Vertex(relatedVertexId) + //skip the current resource if it's referenced in this list. + //also skip the current resource if its a Binary resource (which is a special case) + if vertexId != resourceVertexId(relatedResourceFhir) && relatedResourceFhir.SourceResourceType != "Binary" { + resource.RelatedResourceFhir = append(resource.RelatedResourceFhir, relatedResourceFhir) + } + return false + }) + } + + for resourceType, _ := range resourceListDictionary { + sourceFlatten, sourceFlattenOk := sourceFlattenLevel[strings.ToLower(resourceType)] + + if sourceFlattenOk && sourceFlatten == true { + //if flatten is set to true, we want to flatten the graph. This is usually for non primary source types (eg. Encounter is a source type, but Condition is the primary source type) + + // Step 3: populate related resources for each encounter, flattened + for ndx, _ := range resourceListDictionary[resourceType] { + // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list + flattenRelatedResourcesFn(resourceListDictionary[resourceType][ndx]) + + //sort all related resources (by date, desc) + resourceListDictionary[resourceType][ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType][ndx].RelatedResourceFhir) + } + } else { + // if flatten is set to false, we want to preserve the top relationships in the graph heirarchy. This is usually for primary source types (eg. Condition is the primary source type) + // we want to ensure context is preserved, so we will flatten the graph futher down in the heirarchy + + // Step 4: find all encounters referenced by the root conditions, populate them, then add them to the condition as RelatedResourceFhir + for ndx, _ := range resourceListDictionary[resourceType] { + // this is a "root" condition, + + resourceListDictionary[resourceType][ndx].RelatedResourceFhir = []*models.ResourceFhir{} + vertexId := resourceVertexId(resourceListDictionary[resourceType][ndx]) + for relatedVertexId, _ := range adjacencyMap[vertexId] { + relatedResourceFhir, _ := g.Vertex(relatedVertexId) + flattenRelatedResourcesFn(relatedResourceFhir) + resourceListDictionary[resourceType][ndx].RelatedResourceFhir = append(resourceListDictionary[resourceType][ndx].RelatedResourceFhir, relatedResourceFhir) + } + + //sort all related resources (by date, desc) + resourceListDictionary[resourceType][ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType][ndx].RelatedResourceFhir) + } + } + + resourceListDictionary[resourceType] = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType]) + } + + // Step 5: return the populated resource list dictionary + + return resourceListDictionary, nil +} + +//We need to support the following types of graphs: +// - Medical History +// - AddressBook (contacts) +// - Medications +// - Billing Report +//edges are always "strongly connected", however "source" nodes (roots, like Condition or Encounter -- depending on ) are only one way. +//add an edge from every resource to its related resource. Keep in mind that FHIR resources may not contain reciprocal edges, so we ensure the graph is rooted by flipping any +//related resources that are "Condition" or "Encounter" +func (sr *SqliteRepository) PopulateGraphTypeReciprocalRelationships(graphType pkg.ResourceGraphType, relationships []models.RelatedResource) []models.RelatedResource { + reciprocalRelationships := []models.RelatedResource{} + + //prioritized lists of sources and sinks for the graph. We will use these to determine which resources are "root" nodes. + sources, sinks, _ := getSourcesAndSinksForGraphType(graphType) + + for _, relationship := range relationships { + + //calculate the + resourceAGraphSourceLevel := foundResourceGraphSource(relationship.ResourceFhirSourceResourceType, sources) + resourceBGraphSourceLevel := foundResourceGraphSource(relationship.RelatedResourceFhirSourceResourceType, sources) + + resourceAGraphSinkLevel := foundResourceGraphSink(relationship.ResourceFhirSourceResourceType, sinks) + resourceBGraphSinkLevel := foundResourceGraphSink(relationship.RelatedResourceFhirSourceResourceType, sinks) + + if resourceAGraphSourceLevel > -1 && resourceBGraphSourceLevel > -1 { + //handle the case where both resources are "sources" (eg. MedicalHistory - Condition or Encounter) + if resourceAGraphSourceLevel <= resourceBGraphSourceLevel { + //A is a higher priority than B, so we will add an edge from A to B + reciprocalRelationships = append(reciprocalRelationships, relationship) + } else { + //B is a higher priority than A, so we will add an edge from B to A (flipped relationship) + reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ + ResourceFhirUserID: relationship.RelatedResourceFhirUserID, + ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, + ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, + ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, + RelatedResourceFhirUserID: relationship.ResourceFhirUserID, + RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, + RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, + RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, + }) + } + + } else if resourceAGraphSourceLevel > -1 || resourceBGraphSinkLevel > -1 { + //resource A is a Source, or resource B is a sink, normal A -> B relationship (edge) + reciprocalRelationships = append(reciprocalRelationships, relationship) + + } else if resourceBGraphSourceLevel > -1 || resourceAGraphSinkLevel > -1 { + //resource B is a Source, or resource A is a sink, create B -> A relationship (edge) + + reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ + ResourceFhirUserID: relationship.RelatedResourceFhirUserID, + ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, + ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, + ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, + RelatedResourceFhirUserID: relationship.ResourceFhirUserID, + RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, + RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, + RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, + }) + + } else { + //this is a regular pair of resources, create reciprocal edges + + reciprocalRelationships = append(reciprocalRelationships, relationship) + + reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ + ResourceFhirUserID: relationship.RelatedResourceFhirUserID, + ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, + ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, + ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, + RelatedResourceFhirUserID: relationship.ResourceFhirUserID, + RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, + RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, + RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, + }) + } + + } + + return reciprocalRelationships +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Utilities +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func getSourcesAndSinksForGraphType(graphType pkg.ResourceGraphType) ([][]string, [][]string, map[string]bool) { + var sources [][]string + var sinks [][]string + var sourceFlattenRelated map[string]bool + switch graphType { + case pkg.ResourceGraphTypeMedicalHistory: + sources = [][]string{ + {"condition", "composition"}, + {"encounter"}, + } + sinks = [][]string{ + {"location", "device", "organization", "practitioner", "medication", "patient"}, //resources that are shared across multiple conditions + {"binary"}, + } + sourceFlattenRelated = map[string]bool{ + "encounter": true, + } + break + case pkg.ResourceGraphTypeAddressBook: + sources = [][]string{ + {"practitioner", "organization"}, + {"practitionerrole", "careteam", "location"}, + } + sinks = [][]string{ + {"condition", "composition", "explanationofbenefits"}, //resources that are shared across multiple practitioners + {"encounter", "medication", "patient"}, + } + sourceFlattenRelated = map[string]bool{} + } + return sources, sinks, sourceFlattenRelated +} + +//source resource types are resources that are at the root of the graph, nothing may reference them directly +// loop though the list of source resource types, and see if the checkResourceType is one of them +func foundResourceGraphSource(checkResourceType string, sourceResourceTypes [][]string) int { + found := -1 + for i, sourceResourceType := range sourceResourceTypes { + if slices.Contains(sourceResourceType, strings.ToLower(checkResourceType)) { + found = i + break + } + } + return found +} + +//sink resource types are the leaves of the graph, they must not reference anything else. (only be referenced) +func foundResourceGraphSink(checkResourceType string, sinkResourceTypes [][]string) int { + found := -1 + for i, sinkResourceType := range sinkResourceTypes { + if slices.Contains(sinkResourceType, strings.ToLower(checkResourceType)) { + found = i + break + } + } + return found +} + +// helper function for GetResourceGraph, creating a "hash" for the resource +func resourceVertexId(resource *models.ResourceFhir) string { + return resourceKeysVertexId(resource.SourceID.String(), resource.SourceResourceType, resource.SourceResourceID) +} +func resourceKeysVertexId(sourceId string, resourceType string, resourceId string) string { + return strings.ToLower(fmt.Sprintf("%s/%s/%s", sourceId, resourceType, resourceId)) +} diff --git a/backend/pkg/web/handler/resource_fhir.go b/backend/pkg/web/handler/resource_fhir.go index 47227927..6d5a8ac1 100644 --- a/backend/pkg/web/handler/resource_fhir.go +++ b/backend/pkg/web/handler/resource_fhir.go @@ -97,15 +97,14 @@ func GetResourceFhirGraph(c *gin.Context) { logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) - conditionResourceList, encounterResourceList, err := databaseRepo.GetFlattenedResourceGraph(c) + graphType := strings.Trim(c.Param("graphType"), "/") + + resourceListDictionary, err := databaseRepo.GetFlattenedResourceGraph(c, pkg.ResourceGraphType(graphType)) if err != nil { logger.Errorln("An error occurred while retrieving list of resources", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "data": map[string][]*models.ResourceFhir{ - "Condition": conditionResourceList, - "Encounter": encounterResourceList, - }}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": resourceListDictionary}) } diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 36d5cc90..02656141 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -59,7 +59,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { secure.POST("/source/:sourceId/sync", handler.SourceSync) secure.GET("/source/:sourceId/summary", handler.GetSourceSummary) secure.GET("/resource/fhir", handler.ListResourceFhir) - secure.GET("/resource/graph", handler.GetResourceFhirGraph) + secure.GET("/resource/graph/:graphType", handler.GetResourceFhirGraph) secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir) secure.POST("/resource/composition", handler.CreateResourceComposition) @@ -82,7 +82,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { { //http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992 unsafe.GET("/:username/:sourceId/*path", handler.UnsafeRequestSource) - unsafe.GET("/:username/graph", handler.UnsafeResourceGraph) + unsafe.GET("/:username/graph/:graphType", handler.UnsafeResourceGraph) } } diff --git a/frontend/src/app/pages/medical-history/medical-history.component.ts b/frontend/src/app/pages/medical-history/medical-history.component.ts index 589ffcf0..dc95f536 100644 --- a/frontend/src/app/pages/medical-history/medical-history.component.ts +++ b/frontend/src/app/pages/medical-history/medical-history.component.ts @@ -30,8 +30,8 @@ export class MedicalHistoryComponent implements OnInit { this.loading = true this.fastenApi.getResourceGraph().subscribe(results => { this.loading = false - this.conditions = results["Condition"] - this.unassigned_encounters = results["Encounter"] + this.conditions = [].concat(results["Condition"] || [], results["Composition"] || []) + this.unassigned_encounters = results["Encounter"] || [] //populate a lookup table with all resources for(let condition of this.conditions){ diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 9bed64ce..34a69714 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -148,8 +148,12 @@ export class FastenApiService { ); } - getResourceGraph(): Observable<{[resourceType: string]: ResourceFhir[]}> { - return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph`) + getResourceGraph(graphType?: string): Observable<{[resourceType: string]: ResourceFhir[]}> { + if(!graphType){ + graphType = "MedicalHistory" + } + + return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph/${graphType}`) .pipe( map((response: ResponseWrapper) => { console.log("RESPONSE", response)