diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 5a270ae7..184007a5 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -24,6 +24,8 @@ func (c *configuration) Init() error { c.SetDefault("web.listen.port", "8080") c.SetDefault("web.listen.host", "0.0.0.0") c.SetDefault("web.listen.basepath", "") + c.SetDefault("web.allow_unsafe_endpoints", false) + c.SetDefault("web.src.frontend.path", "/opt/fasten/web") c.SetDefault("database.location", "/opt/fasten/db/fasten.db") //TODO: should be /opt/fasten/fasten.db diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index 33c14fac..281f298b 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -24,6 +24,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) //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 28bf06e1..594359ae 100644 --- a/backend/pkg/database/sqlite_repository.go +++ b/backend/pkg/database/sqlite_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/dominikbraun/graph" sourceModel "github.com/fastenhealth/fasten-sources/clients/models" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" @@ -11,6 +12,7 @@ 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" @@ -202,39 +204,25 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent //note: we create the association in the related_resources table **before** the model actually exists. if rawResource.ReferencedResources != nil { - //these are resources that are referenced by the current resource - relatedResources := []*models.ResourceFhir{} - - //reciprocalRelatedResources := []*models.ResourceFhir{} for _, referencedResource := range rawResource.ReferencedResources { parts := strings.Split(referencedResource, "/") - if len(parts) == 2 { - - relatedResource := &models.ResourceFhir{ - OriginBase: models.OriginBase{ - SourceID: source.ID, - SourceResourceType: parts[0], - SourceResourceID: parts[1], - }, - RelatedResourceFhir: nil, - } - relatedResources = append(relatedResources, relatedResource) - - //if the related resource is an Encounter or Condition, make sure we create a reciprocal association as well, just incase - if parts[0] == "Condition" || parts[0] == "Encounter" { - //manually create association (we've tried to create using Association.Append, and it doesnt work for some reason. - err := sr.AddResourceAssociation(ctx, &source, parts[0], parts[1], &source, wrappedResourceModel.SourceResourceType, wrappedResourceModel.SourceResourceID) - if err != nil { - sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err) - } - } + if len(parts) != 2 { + continue + } + relatedResource := &models.ResourceFhir{ + OriginBase: models.OriginBase{ + SourceID: source.ID, + SourceResourceType: parts[0], + SourceResourceID: parts[1], + }, + RelatedResourceFhir: nil, + } + err := sr.AddReciprocalResourceAssociations(ctx, &source, wrappedResourceModel, &source, relatedResource) + if err != nil { + sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err) } } - - //ignore errors when creating associations (we always get a 'WHERE conditions required' error, ) - sr.GormClient.WithContext(ctx).Model(wrappedResourceModel).Association("RelatedResourceFhir").Append(relatedResources) - } sr.Logger.Infof("insert/update (%v) %v", rawResource.SourceResourceType, rawResource.SourceResourceID) @@ -322,7 +310,7 @@ func (sr *SqliteRepository) ListResources(ctx context.Context, queryOptions mode queryBuilder := sr.GormClient.WithContext(ctx) if queryOptions.PreloadRelated { //enable preload functionality in query - queryBuilder = queryBuilder.Preload("RelatedResourceFhir").Preload("RelatedResourceFhir.RelatedResourceFhir") + queryBuilder = queryBuilder.Preload("RelatedResourceFhir") } results := queryBuilder.Where(queryParam). Find(&wrappedResourceModels) @@ -392,11 +380,159 @@ 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) { + // 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: sr.GetCurrentUser(ctx).ID, + }). + Scan(&relatedResourceRelationships) + if result.Error != nil { + return nil, nil, result.Error + } + + //Generate Graph + 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" { + 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. + if vertexId != resourceVertexId(relatedResourceFhir) { + 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]) + } + + // 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) + } + } + + //TODO: sort conditionList by date + + return conditionList, encounterList, nil +} + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Resource Associations //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error { +//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 { //ensure that the sources are "owned" by the same user if source.UserID != relatedSource.UserID { @@ -405,11 +541,111 @@ func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source * return fmt.Errorf("user id's must match current user") } - //manually create association (we've tried to create using Association.Append, and it doesnt work for some reason. + //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 { return sr.GormClient.WithContext(ctx).Table("related_resources").Create(map[string]interface{}{ + "resource_fhir_user_id": source.UserID, "resource_fhir_source_id": source.ID, "resource_fhir_source_resource_type": resourceType, "resource_fhir_source_resource_id": resourceId, + "related_resource_fhir_user_id": relatedSource.UserID, "related_resource_fhir_source_id": relatedSource.ID, "related_resource_fhir_source_resource_type": relatedResourceType, "related_resource_fhir_source_resource_id": relatedResourceId, @@ -425,9 +661,11 @@ func (sr *SqliteRepository) RemoveResourceAssociation(ctx context.Context, sourc //manually create association (we've tried to create using Association.Append, and it doesnt work for some reason. return sr.GormClient.WithContext(ctx).Table("related_resources").Delete(map[string]interface{}{ + "resource_fhir_user_id": source.UserID, "resource_fhir_source_id": source.ID, "resource_fhir_source_resource_type": resourceType, "resource_fhir_source_resource_id": resourceId, + "related_resource_fhir_user_id": relatedSource.UserID, "related_resource_fhir_source_id": relatedSource.ID, "related_resource_fhir_source_resource_type": relatedResourceType, "related_resource_fhir_source_resource_id": relatedResourceId, @@ -543,3 +781,23 @@ 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/models/related_resource.go b/backend/pkg/models/related_resource.go new file mode 100644 index 00000000..e110667a --- /dev/null +++ b/backend/pkg/models/related_resource.go @@ -0,0 +1,16 @@ +package models + +import "github.com/google/uuid" + +//this model is used by the DB (see ResourceAssociation for web model) +type RelatedResource struct { + ResourceFhirUserID uuid.UUID `gorm:"resource_fhir_user_id"` + ResourceFhirSourceID uuid.UUID `gorm:"resource_fhir_source_id"` + ResourceFhirSourceResourceType string `gorm:"resource_fhir_source_resource_type"` + ResourceFhirSourceResourceID string `gorm:"resource_fhir_source_resource_id"` + + RelatedResourceFhirUserID uuid.UUID `gorm:"related_resource_fhir_user_id"` + RelatedResourceFhirSourceID uuid.UUID `gorm:"related_resource_fhir_source_id"` + RelatedResourceFhirSourceResourceType string `gorm:"related_resource_fhir_source_resource_type"` + RelatedResourceFhirSourceResourceID string `gorm:"related_resource_fhir_source_resource_id"` +} diff --git a/backend/pkg/models/resource_association.go b/backend/pkg/models/resource_association.go index f453df9b..6b711dee 100644 --- a/backend/pkg/models/resource_association.go +++ b/backend/pkg/models/resource_association.go @@ -1,5 +1,6 @@ package models +//this model is used by the Webserver (see RelatedResource for db model) type ResourceAssociation struct { SourceID string `json:"source_id"` SourceResourceType string `json:"source_resource_type"` diff --git a/backend/pkg/models/resource_fhir.go b/backend/pkg/models/resource_fhir.go index cc037a30..3e416759 100644 --- a/backend/pkg/models/resource_fhir.go +++ b/backend/pkg/models/resource_fhir.go @@ -11,7 +11,7 @@ type ResourceFhir struct { ResourceRaw datatypes.JSON `json:"resource_raw" gorm:"resource_raw"` //relationships - RelatedResourceFhir []*ResourceFhir `json:"related_resources" gorm:"many2many:related_resources;ForeignKey:source_id,source_resource_type,source_resource_id;references:source_id,source_resource_type,source_resource_id;"` + RelatedResourceFhir []*ResourceFhir `json:"related_resources" gorm:"many2many:related_resources;ForeignKey:user_id,source_id,source_resource_type,source_resource_id;references:user_id,source_id,source_resource_type,source_resource_id;"` } type ListResourceQueryOptions struct { diff --git a/backend/pkg/web/handler/resource_fhir.go b/backend/pkg/web/handler/resource_fhir.go index b5d26ac4..30a19e18 100644 --- a/backend/pkg/web/handler/resource_fhir.go +++ b/backend/pkg/web/handler/resource_fhir.go @@ -107,3 +107,25 @@ func ReplaceResourceAssociation(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } + +// GetResourceFhirGraph +// Retrieve a list of all fhir resources (vertex), and a list of all associations (edge) +// Generate a graph +// find the PredecessorMap +// - filter to only vertices that are "Condition" or "Encounter" and are "root" nodes (have no edges directed to this node) +func GetResourceFhirGraph(c *gin.Context) { + logger := c.MustGet("LOGGER").(*logrus.Entry) + databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository) + + conditionResourceList, encounterResourceList, err := databaseRepo.GetFlattenedResourceGraph(c) + 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, + }}) +} diff --git a/backend/pkg/web/handler/source.go b/backend/pkg/web/handler/source.go index 6fdbca11..068a9591 100644 --- a/backend/pkg/web/handler/source.go +++ b/backend/pkg/web/handler/source.go @@ -12,8 +12,6 @@ import ( "github.com/sirupsen/logrus" "io/ioutil" "net/http" - "net/url" - "strings" ) func CreateSource(c *gin.Context) { @@ -179,64 +177,6 @@ func ListSource(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCreds}) } -func RawRequestSource(c *gin.Context) { - logger := c.MustGet("LOGGER").(*logrus.Entry) - databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository) - - //!!!!!!INSECURE!!!!!!S - //We're setting the username to a user provided value, this is insecure, but required for calling databaseRepo fns - c.Set("AUTH_USERNAME", c.Param("username")) - - foundSource, err := databaseRepo.GetSource(c, c.Param("sourceId")) - if err != nil { - logger.Errorf("An error occurred while finding source credential: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) - return - } - - if foundSource == nil { - logger.Errorf("Did not source credentials for %s", c.Param("sourceType")) - c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) - return - } - - client, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), foundSource.SourceType, c, logger, foundSource) - if err != nil { - logger.Errorf("Could not initialize source client %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) - return - } - //TODO: if source has been updated, we should save the access/refresh token. - if updatedSource != nil { - logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource) - // err := databaseRepo.CreateSource(c, updatedSource) - // if err != nil { - // logger.Errorf("An error occurred while updating source credential %v", err) - // c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) - // return - // } - } - - var resp map[string]interface{} - - parsedUrl, err := url.Parse(strings.TrimSuffix(c.Param("path"), "/")) - if err != nil { - logger.Errorf("Error parsing request, %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) - return - } - //make sure we include all query string parameters with the raw request. - parsedUrl.RawQuery = c.Request.URL.Query().Encode() - - err = client.GetRequest(parsedUrl.String(), &resp) - if err != nil { - logger.Errorf("Error making raw request, %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error(), "data": resp}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": resp}) -} - func SyncSourceResources(c context.Context, logger *logrus.Entry, databaseRepo database.DatabaseRepository, sourceCred models.SourceCredential) (sourceModels.UpsertSummary, error) { // after creating the source, we should do a bulk import sourceClient, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourceCred.SourceType, c, logger, sourceCred) diff --git a/backend/pkg/web/handler/unsafe.go b/backend/pkg/web/handler/unsafe.go new file mode 100644 index 00000000..830466f3 --- /dev/null +++ b/backend/pkg/web/handler/unsafe.go @@ -0,0 +1,81 @@ +package handler + +import ( + "github.com/fastenhealth/fasten-sources/clients/factory" + sourcePkg "github.com/fastenhealth/fasten-sources/pkg" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config" + "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "net/http" + "net/url" + "strings" +) + +/* +These Endpoints are only available when Fasten is deployed with allow_unsafe_endpoints enabled. +*/ + +func UnsafeRequestSource(c *gin.Context) { + logger := c.MustGet("LOGGER").(*logrus.Entry) + appConfig := c.MustGet("CONFIG").(config.Interface) + databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository) + //safety check incase this function is called in another way + if !appConfig.GetBool("web.allow_unsafe_endpoints") { + c.JSON(http.StatusServiceUnavailable, gin.H{"success": false}) + return + } + + //!!!!!!INSECURE!!!!!!S + //We're setting the username to a user provided value, this is insecure, but required for calling databaseRepo fns + c.Set("AUTH_USERNAME", c.Param("username")) + + foundSource, err := databaseRepo.GetSource(c, c.Param("sourceId")) + if err != nil { + logger.Errorf("An error occurred while finding source credential: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + if foundSource == nil { + logger.Errorf("Did not source credentials for %s", c.Param("sourceType")) + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + return + } + + client, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), foundSource.SourceType, c, logger, foundSource) + if err != nil { + logger.Errorf("Could not initialize source client %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + //TODO: if source has been updated, we should save the access/refresh token. + if updatedSource != nil { + logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource) + // err := databaseRepo.CreateSource(c, updatedSource) + // if err != nil { + // logger.Errorf("An error occurred while updating source credential %v", err) + // c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + // return + // } + } + + var resp map[string]interface{} + + parsedUrl, err := url.Parse(strings.TrimSuffix(c.Param("path"), "/")) + if err != nil { + logger.Errorf("Error parsing request, %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + //make sure we include all query string parameters with the raw request. + parsedUrl.RawQuery = c.Request.URL.Query().Encode() + + err = client.GetRequest(parsedUrl.String(), &resp) + if err != nil { + logger.Errorf("Error making raw request, %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error(), "data": resp}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": resp}) +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index bf8d5dd7..a4527127 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -57,17 +57,31 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { secure.GET("/source/:sourceId", handler.GetSource) secure.POST("/source/:sourceId/sync", handler.SourceSync) secure.GET("/source/:sourceId/summary", handler.GetSourceSummary) - secure.GET("/resource/fhir", handler.ListResourceFhir) // + secure.GET("/resource/fhir", handler.ListResourceFhir) + secure.GET("/resource/graph", handler.GetResourceFhirGraph) secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir) secure.POST("/resource/association", handler.ReplaceResourceAssociation) } - if ae.Config.GetString("log.level") == "DEBUG" { - //in debug mode, this endpoint lets us request data directly from the source api - ae.Logger.Warningf("***INSECURE*** ***INSECURE*** DEBUG mode enables developer functionality, including unauthenticated raw api requests") + if ae.Config.GetBool("web.allow_unsafe_endpoints") { + //this endpoint lets us request data directly from the source api + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningf("\"web.allow_unsafe_endpoints\" mode enabled!! This enables developer functionality, including unauthenticated raw api requests") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + ae.Logger.Warningln("***UNSAFE***") + unsafe := api.Group("/unsafe") + { + //http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992 + unsafe.GET("/:username/:sourceId/*path", handler.UnsafeRequestSource) - //http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992 - api.GET("/raw/:username/:sourceId/*path", handler.RawRequestSource) + } } } } diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index ff5ac66d..5d7b4f2e 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -11,6 +11,7 @@ import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guar import {SourceDetailComponent} from './pages/source-detail/source-detail.component'; import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component'; import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component'; +import {ReportLabsComponent} from './pages/report-labs/report-labs.component'; const routes: Routes = [ @@ -29,6 +30,7 @@ const routes: Routes = [ { path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'medical-history', component: MedicalHistoryComponent, canActivate: [ IsAuthenticatedAuthGuard] }, + { path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, // { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) }, // { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 52fb9c66..6c4b38f1 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -28,6 +28,7 @@ import { MomentModule } from 'ngx-moment'; import {AuthService} from './services/auth.service'; import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component'; import { MedicalHistoryComponent } from './pages/medical-history/medical-history.component'; +import { ReportLabsComponent } from './pages/report-labs/report-labs.component'; @NgModule({ @@ -43,6 +44,7 @@ import { MedicalHistoryComponent } from './pages/medical-history/medical-history SourceDetailComponent, PatientProfileComponent, MedicalHistoryComponent, + ReportLabsComponent, ], imports: [ FormsModule, 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 new file mode 100644 index 00000000..412d0b74 --- /dev/null +++ b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.html @@ -0,0 +1,56 @@ +
+
+
+ +
+ {{observationTitle}} +
+
+ {{observations[0] | fhirPath: "Observation.effectiveDateTime": "Observation.issued" | date}} +
+
+
+
+ +
+ + +
+ +
+
+

+ Test Code (loinc): {{observationCode}}
+ Latest Test Date: {{observations[0] | fhirPath: "Observation.effectiveDateTime": "Observation.issued" | date}}
+ Ordered By: {{observations[0] | fhirPath: "Observation.encounter.display"}}
+ Notes: +

+ + +
+
+ +
+
+
+

+ + Troponin I is a part of the troponin complex. It binds to actin in thin myofilaments to hold the actin-tropomyosin complex in place. Because of it myosin cannot bind actin in relaxed muscle. When calcium binds to the Troponin C it causes conformational changes which lead to dislocation of troponin I and finally tropomyosin leaves the binding site for myosin on actin leading to contraction of muscle. The letter I is given due to its inhibitory character. + +

+ + + + +
+ {{observation | fhirPath: "Observation.valueQuantity.value" }} +
+ +
+
+
+
+ + +
+
diff --git a/frontend/src/app/components/report-labs-observation/report-labs-observation.component.scss b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/components/report-labs-observation/report-labs-observation.component.spec.ts b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.spec.ts new file mode 100644 index 00000000..4268852a --- /dev/null +++ b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReportLabsObservationComponent } from './report-labs-observation.component'; + +describe('ReportLabsObservationComponent', () => { + let component: ReportLabsObservationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ReportLabsObservationComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReportLabsObservationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/report-labs-observation/report-labs-observation.component.ts b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.ts new file mode 100644 index 00000000..9718f304 --- /dev/null +++ b/frontend/src/app/components/report-labs-observation/report-labs-observation.component.ts @@ -0,0 +1,21 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {ResourceFhir} from '../../models/fasten/resource_fhir'; + +@Component({ + selector: 'app-report-labs-observation', + templateUrl: './report-labs-observation.component.html', + styleUrls: ['./report-labs-observation.component.scss'] +}) +export class ReportLabsObservationComponent implements OnInit { + + @Input() observations: ResourceFhir[] + @Input() observationCode: string + @Input() observationTitle: string + + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/frontend/src/app/components/shared.module.ts b/frontend/src/app/components/shared.module.ts index 86eb7d21..0cea7f5c 100644 --- a/frontend/src/app/components/shared.module.ts +++ b/frontend/src/app/components/shared.module.ts @@ -40,6 +40,7 @@ import { ReportMedicalHistoryEditorComponent } from './report-medical-history-ed import { TreeModule } from '@circlon/angular-tree-component'; import {FilterPipe} from '../pipes/filter.pipe'; import { ReportMedicalHistoryConditionComponent } from './report-medical-history-condition/report-medical-history-condition.component'; +import { ReportLabsObservationComponent } from './report-labs-observation/report-labs-observation.component'; @NgModule({ imports: [ @@ -86,6 +87,7 @@ import { ReportMedicalHistoryConditionComponent } from './report-medical-history ReportMedicalHistoryEditorComponent, FilterPipe, ReportMedicalHistoryConditionComponent, + ReportLabsObservationComponent, ], exports: [ ComponentsSidebarComponent, @@ -118,10 +120,11 @@ import { ReportMedicalHistoryConditionComponent } from './report-medical-history ResourceListOutletDirective, ToastComponent, ReportHeaderComponent, - ReportMedicalHistoryEditorComponent, - FhirPathPipe, - FilterPipe, - ReportMedicalHistoryConditionComponent + ReportMedicalHistoryEditorComponent, + FhirPathPipe, + FilterPipe, + ReportMedicalHistoryConditionComponent, + ReportLabsObservationComponent ] }) 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 92976957..aefcd526 100644 --- a/frontend/src/app/pages/medical-history/medical-history.component.ts +++ b/frontend/src/app/pages/medical-history/medical-history.component.ts @@ -25,25 +25,15 @@ export class MedicalHistoryComponent implements OnInit { ngOnInit(): void { - forkJoin([ - this.fastenApi.getResources("Condition", null, null, true), - this.fastenApi.getResources("Encounter", null, null, true) - ]).subscribe(results => { - this.conditions = results[0] + this.fastenApi.getResourceGraph().subscribe(results => { + this.conditions = results["Condition"] + this.unassigned_encounters = results["Encounter"] + //populate a lookup table with all resources for(let condition of this.conditions){ this.recPopulateResourceLookup(condition) } - console.log("Populated resource lookup:", this.resourceLookup); - - //find unassigned encounters - console.log("all encounters:", results[1].length, results[1]); - (results[1] || []).map((encounter) => { - if(!this.resourceLookup[`${encounter.source_id}/${encounter.source_resource_type}/${encounter.source_resource_id}`]){ - this.unassigned_encounters.push(encounter) - } - }) if(this.unassigned_encounters.length > 0){ console.log("Found mapping:", this.resourceLookup) diff --git a/frontend/src/app/pages/report-labs/report-labs.component.html b/frontend/src/app/pages/report-labs/report-labs.component.html new file mode 100644 index 00000000..eb4df820 --- /dev/null +++ b/frontend/src/app/pages/report-labs/report-labs.component.html @@ -0,0 +1,26 @@ +
+
+
+ + + + + + + +
+
+

Observations

+
+
+ + + + +
+
+
diff --git a/frontend/src/app/pages/report-labs/report-labs.component.scss b/frontend/src/app/pages/report-labs/report-labs.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/pages/report-labs/report-labs.component.spec.ts b/frontend/src/app/pages/report-labs/report-labs.component.spec.ts new file mode 100644 index 00000000..d241b2b4 --- /dev/null +++ b/frontend/src/app/pages/report-labs/report-labs.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReportLabsComponent } from './report-labs.component'; + +describe('ReportLabsComponent', () => { + let component: ReportLabsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ReportLabsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReportLabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/report-labs/report-labs.component.ts b/frontend/src/app/pages/report-labs/report-labs.component.ts new file mode 100644 index 00000000..c8a7b517 --- /dev/null +++ b/frontend/src/app/pages/report-labs/report-labs.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; +import {FastenApiService} from '../../services/fasten-api.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ResourceFhir} from '../../models/fasten/resource_fhir'; +import * as fhirpath from 'fhirpath'; + +@Component({ + selector: 'app-report-labs', + templateUrl: './report-labs.component.html', + styleUrls: ['./report-labs.component.scss'] +}) +export class ReportLabsComponent implements OnInit { + + observationGroups: {[key: string]: ResourceFhir[]} = {} + observationGroupTitles: {[key: string]: string} = {} + + constructor( + private fastenApi: FastenApiService, + ) { } + + ngOnInit(): void { + this.fastenApi.getResources("Observation").subscribe(results => { + console.log("ALL OBSERVATIONS", results) + + //loop though all observations, group by "code.system": "http://loinc.org" + for(let observation of results){ + let observationGroup = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().code")[0] + this.observationGroups[observationGroup] = this.observationGroups[observationGroup] ? this.observationGroups[observationGroup] : [] + this.observationGroups[observationGroup].push(observation) + + if(!this.observationGroupTitles[observationGroup]){ + this.observationGroupTitles[observationGroup] = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().display")[0] + } + + } + + //TODO: sort observation groups + + // this.observationGroups = results + // + // //populate a lookup table with all resources + // for (let condition of this.conditions) { + // this.recPopulateResourceLookup(condition) + // } + }) + } + +} diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index d604fbe6..e4886ec8 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -126,6 +126,16 @@ export class FastenApiService { ); } + getResourceGraph(): Observable<{[resourceType: string]: ResourceFhir[]}> { + return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph`) + .pipe( + map((response: ResponseWrapper) => { + console.log("RESPONSE", response) + return response.data as {[name: string]: ResourceFhir[]} + }) + ); + } + getResourceBySourceId(sourceId: string, resourceId: string): Observable { return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir/${sourceId}/${resourceId}`) diff --git a/go.mod b/go.mod index a2f3aecd..f5c616f8 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.18 require ( github.com/analogj/go-util v0.0.0-20210417161720-39b497cca03b + github.com/dominikbraun/graph v0.15.0 github.com/fastenhealth/fasten-sources v0.0.7 github.com/gin-gonic/gin v1.8.1 github.com/glebarez/sqlite v1.5.0 @@ -14,6 +15,7 @@ require ( github.com/spf13/viper v1.12.0 github.com/urfave/cli/v2 v2.11.2 golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 gorm.io/datatypes v1.0.7 gorm.io/gorm v1.24.1 ) @@ -57,7 +59,6 @@ require ( github.com/subosito/gotenv v1.3.0 // indirect github.com/ugorji/go/codec v1.2.7 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/net v0.2.0 // indirect golang.org/x/oauth2 v0.2.0 // indirect golang.org/x/sys v0.2.0 // indirect diff --git a/go.sum b/go.sum index cca84678..69d16be9 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/denisenkom/go-mssqldb v0.12.0 h1:VtrkII767ttSPNRfFekePK3sctr+joXgO58stqQbtUA= github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dominikbraun/graph v0.15.0 h1:U6N0fWJTDsUO9R5NhFE1Xi0YGGQ1+nKV9KO5apClf3E= +github.com/dominikbraun/graph v0.15.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=