diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
deleted file mode 100644
index d542e1cb..00000000
--- a/.idea/dataSources.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
- sqlite.xerial
- true
- org.sqlite.JDBC
- jdbc:sqlite:$PROJECT_DIR$/fasten.db
- $ProjectFileDir$
-
-
- file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.40.1/org/xerial/sqlite-jdbc/3.40.1.0/sqlite-jdbc-3.40.1.0.jar
-
-
-
-
-
diff --git a/backend/cmd/fasten/fasten.go b/backend/cmd/fasten/fasten.go
index c7cd6130..b2ff634e 100644
--- a/backend/cmd/fasten/fasten.go
+++ b/backend/cmd/fasten/fasten.go
@@ -21,7 +21,8 @@ var goos string
var goarch string
func main() {
-
+ log.Print("Starting fasten-onprem")
+ defer log.Print("Finished fasten-onprem")
appconfig, err := config.Create()
if err != nil {
fmt.Printf("FATAL: %+v\n", err)
diff --git a/backend/pkg/database/gorm_common.go b/backend/pkg/database/gorm_common.go
index b892993f..65d0acf2 100644
--- a/backend/pkg/database/gorm_common.go
+++ b/backend/pkg/database/gorm_common.go
@@ -884,6 +884,62 @@ func (gr *GormRepository) GetSources(ctx context.Context) ([]models.SourceCreden
return sourceCreds, results.Error
}
+func (gr *GormRepository) DeleteSource(ctx context.Context, sourceId string) (int64, error) {
+ currentUser, currentUserErr := gr.GetCurrentUser(ctx)
+ if currentUserErr != nil {
+ return 0, currentUserErr
+ }
+
+ if strings.TrimSpace(sourceId) == "" {
+ return 0, fmt.Errorf("sourceId cannot be blank")
+ }
+ //delete all resources for this source
+ sourceUUID, err := uuid.Parse(sourceId)
+ if err != nil {
+ return 0, err
+ }
+
+ rowsEffected := int64(0)
+ resourceTypes := databaseModel.GetAllowedResourceTypes()
+ for _, resourceType := range resourceTypes {
+ tableName, err := databaseModel.GetTableNameByResourceType(resourceType)
+ if err != nil {
+ return 0, err
+ }
+ results := gr.GormClient.WithContext(ctx).
+ Where(models.OriginBase{
+ UserID: currentUser.ID,
+ SourceID: sourceUUID,
+ }).
+ Table(tableName).
+ Delete(&models.ResourceBase{})
+ rowsEffected += results.RowsAffected
+ if results.Error != nil {
+ return rowsEffected, results.Error
+ }
+ }
+
+ //delete relatedResources entries
+ results := gr.GormClient.WithContext(ctx).
+ Where(models.RelatedResource{ResourceBaseUserID: currentUser.ID, ResourceBaseSourceID: sourceUUID}).
+ Delete(&models.RelatedResource{})
+ if results.Error != nil {
+ return rowsEffected, results.Error
+ }
+
+ //soft delete the source credential
+ results = gr.GormClient.WithContext(ctx).
+ Where(models.SourceCredential{
+ ModelBase: models.ModelBase{
+ ID: sourceUUID,
+ },
+ UserID: currentUser.ID,
+ }).
+ Delete(&models.SourceCredential{})
+ rowsEffected += results.RowsAffected
+ return rowsEffected, results.Error
+}
+
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Background Job
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go
index a6664b50..d42a4101 100644
--- a/backend/pkg/database/interface.go
+++ b/backend/pkg/database/interface.go
@@ -38,6 +38,7 @@ type DatabaseRepository interface {
GetSourceSummary(context.Context, string) (*models.SourceSummary, error)
GetSources(context.Context) ([]models.SourceCredential, error)
UpdateSource(ctx context.Context, sourceCreds *models.SourceCredential) error
+ DeleteSource(ctx context.Context, sourceId string) (int64, error)
CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error
GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error)
diff --git a/backend/pkg/models/source_credential.go b/backend/pkg/models/source_credential.go
index b3411c8f..f8f08bc6 100644
--- a/backend/pkg/models/source_credential.go
+++ b/backend/pkg/models/source_credential.go
@@ -1,6 +1,7 @@
package models
import (
+ "bytes"
"encoding/json"
"fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg/jwk"
@@ -134,7 +135,90 @@ func (s *SourceCredential) IsDynamicClient() bool {
return len(s.DynamicClientRegistrationMode) > 0
}
+// This method will generate a new keypair, register a new dynamic client with the provider
+// it will set the following fields:
+// - DynamicClientJWKS
+// - DynamicClientId
+func (s *SourceCredential) RegisterDynamicClient() error {
+
+ //this source requires dynamic client registration
+ // see https://fhir.epic.com/Documentation?docId=Oauth2§ion=Standalone-Oauth2-OfflineAccess-0
+
+ // Generate a public-private key pair
+ // Must be 2048 bits (larger keys will silently fail when used with Epic, untested on other providers)
+ sourceSpecificClientKeyPair, err := jwk.JWKGenerate()
+ if err != nil {
+ return fmt.Errorf("an error occurred while generating device-specific keypair for dynamic client: %w", err)
+ }
+
+ //store in sourceCredential
+ serializedKeypair, err := jwk.JWKSerialize(sourceSpecificClientKeyPair)
+ if err != nil {
+ return fmt.Errorf("an error occurred while serializing keypair for dynamic client: %w", err)
+ }
+ s.DynamicClientJWKS = []map[string]string{
+ serializedKeypair,
+ }
+
+ //generate dynamic client registration request
+ payload := ClientRegistrationRequest{
+ SoftwareId: s.ClientId,
+ Jwks: ClientRegistrationRequestJwks{
+ Keys: []ClientRegistrationRequestJwksKey{
+ {
+ KeyType: "RSA",
+ KeyId: serializedKeypair["kid"],
+ Modulus: serializedKeypair["n"],
+ PublicExponent: serializedKeypair["e"],
+ },
+ },
+ },
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("an error occurred while marshalling dynamic client registration request: %w", err)
+ }
+
+ //http.Post("https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token", "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=%s&scope=system/Patient.read", sourceSpecificClientKeyPair))))
+ req, err := http.NewRequest(http.MethodPost, s.RegistrationEndpoint, bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ return fmt.Errorf("an error occurred while generating dynamic client registration request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken))
+
+ registrationResponse, err := http.DefaultClient.Do(req)
+
+ if err != nil {
+ return fmt.Errorf("an error occurred while sending dynamic client registration request: %w", err)
+ }
+ defer registrationResponse.Body.Close()
+ if registrationResponse.StatusCode >= 300 || registrationResponse.StatusCode < 200 {
+ b, err := io.ReadAll(registrationResponse.Body)
+ if err == nil {
+ log.Printf("Error Response body: %s", string(b))
+ }
+ return fmt.Errorf("an error occurred while reading dynamic client registration response, status code was not 200: %d", registrationResponse.StatusCode)
+
+ }
+
+ //read response
+ var registrationResponseBytes ClientRegistrationResponse
+ err = json.NewDecoder(registrationResponse.Body).Decode(®istrationResponseBytes)
+ if err != nil {
+ return fmt.Errorf("an error occurred while parsing dynamic client registration response: %w", err)
+ }
+
+ //store the dynamic client id
+ s.DynamicClientId = registrationResponseBytes.ClientId
+ return nil
+}
+
// this will set/update the AccessToken and Expiry using the dynamic client credentials
+// it will set the following fields:
+// - AccessToken
+// - ExpiresAt
func (s *SourceCredential) RefreshDynamicClientAccessToken() error {
if len(s.DynamicClientRegistrationMode) == 0 {
return fmt.Errorf("dynamic client registration mode not set")
diff --git a/backend/pkg/web/handler/dashboard/default.json b/backend/pkg/web/handler/dashboard/default.json
index 0f4df93c..745b66b8 100644
--- a/backend/pkg/web/handler/dashboard/default.json
+++ b/backend/pkg/web/handler/dashboard/default.json
@@ -5,124 +5,29 @@
"description": "An example dashboard to show-off the power of Fasten widgets",
"widgets": [
{
- "title_text": "Diabetes Tracking",
+ "title_text": "Records Summary",
"description_text": "Track key metrics for your chronic disease (eg. Diabetes). The data within this widget is not reflective of your health record, and is only present for demonstrational purposes.",
"x": 0,
"y": 0,
"width": 8,
- "height": 5,
- "item_type": "complex-line-widget"
- },
- {
- "title_text": "Weight",
- "description_text": "",
- "x": 8,
- "y": 0,
- "width": 2,
- "height": 2,
- "item_type": "simple-line-chart-widget",
- "queries": [{
- "q": {
- "select": [
- "valueQuantity.value as data",
- "valueQuantity.unit as unit",
- "(effectiveDateTime | issued).first() as label"
- ],
- "from": "Observation",
- "where": {
- "code": "http://loinc.org|29463-7,http://loinc.org|3141-9,http://snomed.info/sct|27113001"
- }
- }
- }],
- "parsing": {
- "xAxisKey": "label",
- "yAxisKey": "data"
- }
- },
- {
- "title_text": "Height",
- "description_text": "",
- "x": 10,
- "y": 0,
- "width": 2,
- "height": 2,
- "item_type": "simple-line-chart-widget",
- "queries": [{
- "q": {
- "select": [
- "valueQuantity.value as data",
- "valueQuantity.unit as unit",
- "(effectiveDateTime | issued).first() as label"
- ],
- "from": "Observation",
- "where": {
- "code": "http://loinc.org|8302-2"
- }
- }
- }],
- "parsing": {
- "xAxisKey": "label",
- "yAxisKey": "data"
- }
- },
- {
- "title_text": "Blood Pressure",
- "description_text": "How much pressure your blood is exerting against your artery walls when the heart beats",
- "x": 8,
- "y": 2,
- "width": 4,
- "height": 3,
- "item_type": "grouped-bar-chart-widget",
- "queries": [
- {
- "q": {
- "select": [
- "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8462-4').valueQuantity.value as data",
- "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8462-4').valueQuantity.unit as unit"
- ],
- "from": "Observation",
- "where": {
- "componentCode": "http://loinc.org|8462-4"
- }
- },
- "dataset_options": {
- "label": "Diastolic"
- }
- },
- {
- "q": {
- "select": [
- "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8480-6').valueQuantity.value as data",
- "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8480-6').valueQuantity.unit as unit"
- ],
- "from": "Observation",
- "where": {
- "componentCode": "http://loinc.org|8480-6"
- }
- },
- "dataset_options": {
- "label": "Systolic"
- }
- }],
- "parsing": {
- "xAxisKey": "id",
- "yAxisKey": "data"
- }
+ "height": 6,
+ "item_type": "records-summary-widget"
},
{
"title_text": "Patient Vitals",
"description_text": "",
- "x": 0,
- "y": 5,
+ "x": 8,
+ "y": 0,
"width": 4,
- "height": 5,
+ "height": 6,
"item_type": "patient-vitals-widget"
},
+
{
"title_text": "Observations by Type",
"description_text": "",
- "x": 4,
- "y": 5,
+ "x": 0,
+ "y": 6,
"width": 8,
"height": 5,
"item_type": "donut-chart-widget",
@@ -141,11 +46,113 @@
"key": "value"
}
},
+
+ {
+ "title_text": "Weight",
+ "description_text": "",
+ "x": 8,
+ "y": 6,
+ "width": 2,
+ "height": 2,
+ "item_type": "simple-line-chart-widget",
+ "queries": [{
+ "q": {
+ "select": [
+ "valueQuantity.value as data",
+ "valueQuantity.unit as unit",
+ "(effectiveDateTime | issued).first() as label"
+ ],
+ "from": "Observation",
+ "where": {
+ "code": "http://loinc.org|29463-7,http://loinc.org|3141-9,http://snomed.info/sct|27113001"
+ },
+ "limit": 50
+ }
+ }],
+ "parsing": {
+ "xAxisKey": "label",
+ "yAxisKey": "data"
+ }
+ },
+ {
+ "title_text": "Height",
+ "description_text": "",
+ "x": 10,
+ "y": 6,
+ "width": 2,
+ "height": 2,
+ "item_type": "simple-line-chart-widget",
+ "queries": [{
+ "q": {
+ "select": [
+ "valueQuantity.value as data",
+ "valueQuantity.unit as unit",
+ "(effectiveDateTime | issued).first() as label"
+ ],
+ "from": "Observation",
+ "where": {
+ "code": "http://loinc.org|8302-2"
+ },
+ "limit": 50
+ }
+ }],
+ "parsing": {
+ "xAxisKey": "label",
+ "yAxisKey": "data"
+ }
+ },
+ {
+ "title_text": "Blood Pressure",
+ "description_text": "How much pressure your blood is exerting against your artery walls when the heart beats",
+ "x": 8,
+ "y": 8,
+ "width": 4,
+ "height": 3,
+ "item_type": "grouped-bar-chart-widget",
+ "queries": [
+ {
+ "q": {
+ "select": [
+ "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8462-4').valueQuantity.value as data",
+ "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8462-4').valueQuantity.unit as unit"
+ ],
+ "from": "Observation",
+ "where": {
+ "componentCode": "http://loinc.org|8462-4"
+ },
+ "limit": 50
+ },
+ "dataset_options": {
+ "label": "Diastolic"
+ }
+ },
+ {
+ "q": {
+ "select": [
+ "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8480-6').valueQuantity.value as data",
+ "component.where(code.coding.system = 'http://loinc.org' and code.coding.code = '8480-6').valueQuantity.unit as unit"
+ ],
+ "from": "Observation",
+ "where": {
+ "componentCode": "http://loinc.org|8480-6"
+ },
+ "limit": 50
+ },
+ "dataset_options": {
+ "label": "Systolic"
+ }
+ }],
+ "parsing": {
+ "xAxisKey": "id",
+ "yAxisKey": "data"
+ }
+ },
+
{
"title_text": "Compliance",
"description_text": "Use to track important healthcare and medical tasks.",
"x": 0,
- "y": 10,
+ "y": 11,
"width": 4,
"height": 2,
"item_type": "dual-gauges-widget",
@@ -184,7 +191,7 @@
"title_text": "Recent Encounters",
"description_text": "Recent interactions with healthcare providers",
"x": 4,
- "y": 10,
+ "y": 11,
"width": 8,
"height": 4,
"item_type": "table-widget",
@@ -197,7 +204,8 @@
"participant.individual.display as provider"
],
"from": "Encounter",
- "where": {}
+ "where": {},
+ "limit": 50
}
}],
"parsing": {
diff --git a/backend/pkg/web/handler/dashboard/secondary.json b/backend/pkg/web/handler/dashboard/secondary.json
new file mode 100644
index 00000000..0d7946bb
--- /dev/null
+++ b/backend/pkg/web/handler/dashboard/secondary.json
@@ -0,0 +1,26 @@
+{
+ "id": "secondary",
+ "schema_version": "1.0",
+ "title": "Secondary Dashboard",
+ "description": "An second dashboard to show-off the flexibility of the dashboard system.",
+ "widgets": [
+ {
+ "title_text": "Records Summary",
+ "description_text": "Track key metrics for your chronic disease (eg. Diabetes). The data within this widget is not reflective of your health record, and is only present for demonstrational purposes.",
+ "x": 0,
+ "y": 0,
+ "width": 8,
+ "height": 6,
+ "item_type": "records-summary-widget"
+ },
+ {
+ "title_text": "Care Team",
+ "description_text": "Track key metrics for your chronic disease (eg. Diabetes). The data within this widget is not reflective of your health record, and is only present for demonstrational purposes.",
+ "x": 8,
+ "y": 0,
+ "width": 4,
+ "height": 6,
+ "item_type": "image-list-group-widget"
+ }
+ ]
+}
diff --git a/backend/pkg/web/handler/source.go b/backend/pkg/web/handler/source.go
index a5b201bd..c1b5cdd3 100644
--- a/backend/pkg/web/handler/source.go
+++ b/backend/pkg/web/handler/source.go
@@ -1,24 +1,21 @@
package handler
import (
- "bytes"
- "encoding/json"
"fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/database"
"github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus"
- "github.com/fastenhealth/fasten-onprem/backend/pkg/jwk"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/fastenhealth/fasten-sources/clients/factory"
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"github.com/sirupsen/logrus"
- "io"
"io/ioutil"
"net/http"
)
-func CreateSource(c *gin.Context) {
+func CreateReconnectSource(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
@@ -39,91 +36,13 @@ func CreateSource(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false})
return
}
- //this source requires dynamic client registration
- // see https://fhir.epic.com/Documentation?docId=Oauth2§ion=Standalone-Oauth2-OfflineAccess-0
- // Generate a public-private key pair
- // Must be 2048 bits (larger keys will silently fail when used with Epic, untested on other providers)
- sourceSpecificClientKeyPair, err := jwk.JWKGenerate()
+ err := sourceCred.RegisterDynamicClient()
if err != nil {
- logger.Errorln("An error occurred while generating device-specific keypair for dynamic client", err)
+ logger.Errorln("An error occurred while registering dynamic client", err)
c.JSON(http.StatusBadRequest, gin.H{"success": false})
return
}
- //store in sourceCredential
- serializedKeypair, err := jwk.JWKSerialize(sourceSpecificClientKeyPair)
- if err != nil {
- logger.Errorln("An error occurred while serializing keypair for dynamic client", err)
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
- sourceCred.DynamicClientJWKS = []map[string]string{
- serializedKeypair,
- }
-
- //generate dynamic client registration request
- payload := models.ClientRegistrationRequest{
- SoftwareId: sourceCred.ClientId,
- Jwks: models.ClientRegistrationRequestJwks{
- Keys: []models.ClientRegistrationRequestJwksKey{
- {
- KeyType: "RSA",
- KeyId: serializedKeypair["kid"],
- Modulus: serializedKeypair["n"],
- PublicExponent: serializedKeypair["e"],
- },
- },
- },
- }
- payloadBytes, err := json.Marshal(payload)
- if err != nil {
- logger.Errorln("An error occurred while marshalling dynamic client registration request", err)
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
-
- //http.Post("https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token", "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=%s&scope=system/Patient.read", sourceSpecificClientKeyPair))))
- req, err := http.NewRequest(http.MethodPost, sourceCred.RegistrationEndpoint, bytes.NewBuffer(payloadBytes))
- if err != nil {
- logger.Errorln("An error occurred while generating dynamic client registration request", err)
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sourceCred.AccessToken))
-
- registrationResponse, err := http.DefaultClient.Do(req)
-
- if err != nil {
- logger.Errorln("An error occurred while sending dynamic client registration request", err)
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
- defer registrationResponse.Body.Close()
- if registrationResponse.StatusCode >= 300 || registrationResponse.StatusCode < 200 {
- logger.Errorln("An error occurred while reading dynamic client registration response, status code was not 200", registrationResponse.StatusCode)
- b, err := io.ReadAll(registrationResponse.Body)
- if err == nil {
- logger.Printf("Error Response body: %s", string(b))
- }
-
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
-
- //read response
- var registrationResponseBytes models.ClientRegistrationResponse
- err = json.NewDecoder(registrationResponse.Body).Decode(®istrationResponseBytes)
- if err != nil {
- logger.Errorln("An error occurred while parsing dynamic client registration response", err)
- c.JSON(http.StatusBadRequest, gin.H{"success": false})
- return
- }
-
- //store the dynamic client id
- sourceCred.DynamicClientId = registrationResponseBytes.ClientId
-
//generate a JWT token and then use it to get an access token for the dynamic client
err = sourceCred.RefreshDynamicClientAccessToken()
if err != nil {
@@ -133,11 +52,22 @@ func CreateSource(c *gin.Context) {
}
}
- err := databaseRepo.CreateSource(c, &sourceCred)
- if err != nil {
- logger.Errorln("An error occurred while storing source credential", err)
- c.JSON(http.StatusInternalServerError, gin.H{"success": false})
- return
+ if sourceCred.ID != uuid.Nil {
+ //reconnect
+ err := databaseRepo.UpdateSource(c, &sourceCred)
+ if err != nil {
+ logger.Errorln("An error occurred while reconnecting source credential", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+ return
+ }
+ } else {
+ //create source for the first time
+ err := databaseRepo.CreateSource(c, &sourceCred)
+ if err != nil {
+ logger.Errorln("An error occurred while storing source credential", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+ return
+ }
}
// after creating the source, we should do a bulk import (in the background)
@@ -312,3 +242,16 @@ func ListSource(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCreds})
}
+
+func DeleteSource(c *gin.Context) {
+ logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
+ databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
+
+ rowsEffected, err := databaseRepo.DeleteSource(c, c.Param("sourceId"))
+ if err != nil {
+ logger.Errorln("An error occurred while deleting source credential", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": rowsEffected})
+}
diff --git a/backend/pkg/web/handler/unsafe.go b/backend/pkg/web/handler/unsafe.go
index ed3fb987..5b6de167 100644
--- a/backend/pkg/web/handler/unsafe.go
+++ b/backend/pkg/web/handler/unsafe.go
@@ -1,7 +1,6 @@
package handler
import (
- "fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/config"
"github.com/fastenhealth/fasten-onprem/backend/pkg/database"
@@ -53,16 +52,6 @@ func UnsafeRequestSource(c *gin.Context) {
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{}
@@ -75,6 +64,23 @@ func UnsafeRequestSource(c *gin.Context) {
//make sure we include all query string parameters with the raw request.
parsedUrl.RawQuery = c.Request.URL.Query().Encode()
+ //make sure we store the source credential information in the database, even if the request fails
+ defer func() {
+ //update source incase the access token/refresh token has been updated
+ sourceCredential := client.GetSourceCredential()
+ sourceCredentialConcrete, ok := sourceCredential.(*models.SourceCredential)
+ if !ok {
+ logger.Errorln("An error occurred while updating source credential, source credential is not of type *models.SourceCredential")
+ return
+ }
+ err = databaseRepo.UpdateSource(c, sourceCredentialConcrete)
+ if err != nil {
+ logger.Errorf("An error occurred while updating source credential: %v", err)
+ return
+ }
+ logger.Info("Successfully updated source credential")
+ }()
+
_, err = client.GetRequest(parsedUrl.String(), &resp)
if err != nil {
logger.Errorf("Error making raw request, %v", err)
@@ -82,21 +88,6 @@ func UnsafeRequestSource(c *gin.Context) {
return
}
- //update source incase the access token/refresh token has been updated
- sourceCredential := client.GetSourceCredential()
- sourceCredentialConcrete, ok := sourceCredential.(*models.SourceCredential)
- if !ok {
- logger.Errorln("An error occurred while updating source credential, source credential is not of type *models.SourceCredential")
- c.JSON(http.StatusOK, gin.H{"success": false, "data": resp, "error": fmt.Errorf("An error occurred while updating source credential, source credential is not of type *models.SourceCredential")})
- return
- }
- err = databaseRepo.UpdateSource(c, sourceCredentialConcrete)
- if err != nil {
- logger.Errorf("An error occurred while updating source credential: %v", err)
- c.JSON(http.StatusOK, gin.H{"success": false, "data": resp, "error": fmt.Errorf("An error occurred while updating source credential: %v", err)})
- 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 3b8a9f1a..6f469771 100644
--- a/backend/pkg/web/server.go
+++ b/backend/pkg/web/server.go
@@ -64,10 +64,11 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) {
{
secure.GET("/summary", handler.GetSummary)
- secure.POST("/source", handler.CreateSource)
+ secure.POST("/source", handler.CreateReconnectSource)
secure.POST("/source/manual", handler.CreateManualSource)
secure.GET("/source", handler.ListSource)
secure.GET("/source/:sourceId", handler.GetSource)
+ secure.DELETE("/source/:sourceId", handler.DeleteSource)
secure.POST("/source/:sourceId/sync", handler.SourceSync)
secure.GET("/source/:sourceId/summary", handler.GetSourceSummary)
secure.GET("/resource/fhir", handler.ListResourceFhir)
diff --git a/frontend/src/app/components/gridstack/gridstack.component.ts b/frontend/src/app/components/gridstack/gridstack.component.ts
index 940b178d..b0dc1e32 100644
--- a/frontend/src/app/components/gridstack/gridstack.component.ts
+++ b/frontend/src/app/components/gridstack/gridstack.component.ts
@@ -11,15 +11,7 @@ import { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode, GridSta
import { GridItemCompHTMLElement, GridstackItemComponent } from './gridstack-item.component';
import {CommonModule} from '@angular/common';
-import {WidgetsModule} from '../../widgets/widgets.module';
-import {ComplexLineWidgetComponent} from '../../widgets/complex-line-widget/complex-line-widget.component';
-import {DashboardWidgetComponent} from '../../widgets/dashboard-widget/dashboard-widget.component';
-import {DonutChartWidgetComponent} from '../../widgets/donut-chart-widget/donut-chart-widget.component';
-import {DualGaugesWidgetComponent} from '../../widgets/dual-gauges-widget/dual-gauges-widget.component';
-import {GroupedBarChartWidgetComponent} from '../../widgets/grouped-bar-chart-widget/grouped-bar-chart-widget.component';
-import {PatientVitalsWidgetComponent} from '../../widgets/patient-vitals-widget/patient-vitals-widget.component';
-import {SimpleLineChartWidgetComponent} from '../../widgets/simple-line-chart-widget/simple-line-chart-widget.component';
-import {TableWidgetComponent} from '../../widgets/table-widget/table-widget.component';
+import {WidgetsModule, WidgetComponents} from '../../widgets/widgets.module';
import {DashboardWidgetComponentInterface} from '../../widgets/dashboard-widget-component-interface';
/** events handlers emitters signature for different events */
@@ -137,16 +129,7 @@ export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy {
) {
// register all our dynamic components created in the grid
- GridstackComponent.addComponentToSelectorType([
- ComplexLineWidgetComponent,
- DashboardWidgetComponent,
- DonutChartWidgetComponent,
- DualGaugesWidgetComponent,
- GroupedBarChartWidgetComponent,
- PatientVitalsWidgetComponent,
- SimpleLineChartWidgetComponent,
- TableWidgetComponent,
- ]);
+ GridstackComponent.addComponentToSelectorType(WidgetComponents());
// set globally our method to create the right widget type
GridStack.addRemoveCB = gsCreateNgComponents;
GridStack.saveCB = gsSaveAdditionalNgInfo;
diff --git a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.html b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.html
index c982e07a..212bf223 100644
--- a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.html
+++ b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.html
@@ -13,7 +13,7 @@
diff --git a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.scss b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.scss
index acf7cbdc..db513748 100644
--- a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.scss
+++ b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.scss
@@ -1,5 +1,5 @@
.img-fluid {
- min-height:50px;
+ width:100%;
}
.border-left-danger {
diff --git a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.ts b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.ts
index 52a79ecc..740774ae 100644
--- a/frontend/src/app/components/medical-sources-card/medical-sources-card.component.ts
+++ b/frontend/src/app/components/medical-sources-card/medical-sources-card.component.ts
@@ -1,5 +1,6 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {SourceListItem} from '../../pages/medical-sources/medical-sources.component';
+import moment from 'moment/moment';
@Component({
selector: 'app-medical-sources-card',
@@ -22,4 +23,14 @@ export class MedicalSourcesCardComponent implements OnInit {
this.onClick.emit(this.sourceInfo)
}
+ getSourceDisplayName(sourceItem: SourceListItem): string {
+ if(sourceItem.metadata?.display) {
+ return sourceItem.metadata?.display
+ }
+ if(sourceItem.source?.source_type == 'manual') {
+ return 'Uploaded ' + moment(sourceItem.source?.created_at).format('MMM DD, YYYY')
+ }
+ return "Unknown"
+ }
+
}
diff --git a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html
index 4c694a09..27bf1c74 100644
--- a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html
+++ b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html
@@ -42,10 +42,9 @@
Actions
- Sync
-
- Reconnect
- Delete
+ Sync
+ Reconnect
+ Delete
Close
diff --git a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts
index e1556319..3ba0a0ed 100644
--- a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts
+++ b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts
@@ -11,6 +11,7 @@ import {ToastService} from '../../services/toast.service';
import {ActivatedRoute, Router} from '@angular/router';
import {Location} from '@angular/common';
import {EventBusService} from '../../services/event-bus.service';
+import {SourceState} from '../../models/fasten/source-state';
@Component({
selector: 'app-medical-sources-connected',
@@ -102,7 +103,7 @@ export class MedicalSourcesConnectedComponent implements OnInit {
});
this.location.replaceState(urlTree.toString());
- const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState))
+ const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState)) as SourceState
localStorage.removeItem(callbackState)
if(callbackError && !callbackCode){
@@ -147,6 +148,7 @@ export class MedicalSourcesConnectedComponent implements OnInit {
//Create FHIR Client
const dbSourceCredential = new Source({
+ id: expectedSourceStateInfo.reconnect_source_id,
source_type: sourceType,
authorization_endpoint: sourceMetadata.authorization_endpoint,
@@ -323,6 +325,70 @@ export class MedicalSourcesConnectedComponent implements OnInit {
)
}
+ public sourceDeleteHandler(){
+ let source = this.modalSelectedSourceListItem.source
+ let sourceDisplayName = this.modalSelectedSourceListItem?.metadata?.display || this.modalSelectedSourceListItem?.source?.source_type || 'unknown'
+
+ this.status[source.id] = "authorize"
+ this.modalService.dismissAll()
+
+ this.fastenApi.deleteSource(source.id).subscribe(
+ (respData) => {
+ delete this.status[source.id]
+ delete this.status[source.source_type]
+
+ //delete this source from the connnected list
+ let foundIndex = this.connectedSourceList.findIndex((connectedSource) => {
+ return connectedSource?.source?.id == source.id
+ }, this)
+ if(foundIndex > -1){
+ this.connectedSourceList.splice(foundIndex, 1)
+ }
+
+ console.log("source delete response:", respData)
+
+
+ const toastNotification = new ToastNotification()
+ toastNotification.type = ToastType.Success
+ toastNotification.message = `Successfully deleted source: ${sourceDisplayName}, ${respData} row(s) effected`
+ this.toastService.show(toastNotification)
+
+ },
+ (err) => {
+ delete this.status[source.id]
+ delete this.status[source.source_type]
+
+ const toastNotification = new ToastNotification()
+ toastNotification.type = ToastType.Error
+ toastNotification.message = `An error occurred while deleting source: ${sourceDisplayName}`
+ this.toastService.show(toastNotification)
+ console.log(err)
+ })
+ }
+
+ //this is similar to the connectHandler in the MedicalSourcesComponent
+ public sourceReconnectHandler(selectedSourceListItem: SourceListItem){
+
+ let sourceType = selectedSourceListItem.metadata.source_type
+ this.lighthouseApi.getLighthouseSource(sourceType)
+ .then(async (sourceMetadata: LighthouseSourceMetadata) => {
+ console.log(sourceMetadata);
+ let authorizationUrl = await this.lighthouseApi.generateSourceAuthorizeUrl(sourceType, sourceMetadata, selectedSourceListItem.source.id)
+
+ console.log('authorize url:', authorizationUrl.toString());
+ // redirect to lighthouse with uri's (or open a new window in desktop mode)
+ this.lighthouseApi.redirectWithOriginAndDestination(authorizationUrl.toString(), sourceType, sourceMetadata.redirect_uri).subscribe((codeData) => {
+ //Note: this code will only run in Desktop mode (with popups)
+ //in non-desktop environments, the user is redirected in the same window, and this code is never executed.
+
+ //always close the modal
+ this.modalService.dismissAll()
+
+ //redirect the browser back to this page with the code in the query string parameters
+ this.lighthouseApi.redirectWithDesktopCode(sourceType, codeData)
+ })
+ });
+ }
private getDismissReason(reason: any): string {
if (reason === ModalDismissReasons.ESC) {
diff --git a/frontend/src/app/models/fasten/source-state.ts b/frontend/src/app/models/fasten/source-state.ts
index 9cf4d282..328edb57 100644
--- a/frontend/src/app/models/fasten/source-state.ts
+++ b/frontend/src/app/models/fasten/source-state.ts
@@ -2,6 +2,8 @@ export class SourceState {
state: string
source_type: string //used to override the source_type for sources which have a single redirect url (eg. Epic)
+ reconnect_source_id?: string //used to reconnect a source
+
code_verifier?: string
code_challenge_method?: string
code_challenge?: string
diff --git a/frontend/src/app/models/fasten/source.ts b/frontend/src/app/models/fasten/source.ts
index 722e7130..0bd2bf8f 100644
--- a/frontend/src/app/models/fasten/source.ts
+++ b/frontend/src/app/models/fasten/source.ts
@@ -3,6 +3,8 @@ import {BackgroundJob} from './background-job';
export class Source extends LighthouseSourceMetadata{
id?: string
+ created_at?: string
+ updated_at?: string
user_id?: number
source_type: string
latest_background_job?: BackgroundJob
diff --git a/frontend/src/app/models/widget/dashboard-widget-config.ts b/frontend/src/app/models/widget/dashboard-widget-config.ts
index 0f0fed36..c00d735a 100644
--- a/frontend/src/app/models/widget/dashboard-widget-config.ts
+++ b/frontend/src/app/models/widget/dashboard-widget-config.ts
@@ -3,7 +3,7 @@ import * as _ from 'lodash';
export class DashboardWidgetConfig {
id?: string
- item_type: "complex-line-widget" | "donut-chart-widget" | "dual-gauges-widget" | "grouped-bar-chart-widget" | "patient-vitals-widget" | "simple-line-chart-widget" | "table-widget"
+ item_type: "image-list-group-widget" | "complex-line-widget" | "donut-chart-widget" | "dual-gauges-widget" | "grouped-bar-chart-widget" | "patient-vitals-widget" | "simple-line-chart-widget" | "table-widget" | "records-summary-widget"
title_text: string
description_text: string
diff --git a/frontend/src/app/pages/medical-sources/medical-sources.component.ts b/frontend/src/app/pages/medical-sources/medical-sources.component.ts
index f5850894..32189b11 100644
--- a/frontend/src/app/pages/medical-sources/medical-sources.component.ts
+++ b/frontend/src/app/pages/medical-sources/medical-sources.component.ts
@@ -5,7 +5,7 @@ import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-sourc
import {Source} from '../../models/fasten/source';
import {MetadataSource} from '../../models/fasten/metadata-source';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
-import {ActivatedRoute, Router, UrlSerializer} from '@angular/router';
+import {ActivatedRoute} from '@angular/router';
import {environment} from '../../../environments/environment';
import {BehaviorSubject, forkJoin, Observable, of, Subject} from 'rxjs';
import {
@@ -17,7 +17,6 @@ import {debounceTime, distinctUntilChanged, pairwise, startWith} from 'rxjs/oper
import {MedicalSourcesFilter, MedicalSourcesFilterService} from '../../services/medical-sources-filter.service';
import {FormControl, FormGroup} from '@angular/forms';
import * as _ from 'lodash';
-import {Location} from '@angular/common';
export const sourceConnectWindowTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120)
@@ -79,9 +78,7 @@ export class MedicalSourcesComponent implements OnInit {
private activatedRoute: ActivatedRoute,
private filterService: MedicalSourcesFilterService,
private modalService: NgbModal,
- private router: Router,
- private urlSerializer: UrlSerializer,
- private location: Location,
+
) {
this.filterService.filterChanges.subscribe((filterInfo) => {
@@ -295,22 +292,8 @@ export class MedicalSourcesComponent implements OnInit {
//always close the modal
this.modalService.dismissAll()
- if(!codeData){
- //if we redirected completely, no callback data will be present.
- return
- }
-
- //User was shown a popup, which was closed, and data was returned using events
- //redirect to callback page with code
-
- let urlTree = this.router.createUrlTree(
- ['/sources/callback/' + sourceType],
- { queryParams: codeData, }
- );
-
- let absUrl = this.location.prepareExternalUrl(this.urlSerializer.serialize(urlTree))
- console.log(absUrl);
- window.location.replace(absUrl)
+ //redirect the browser back to this page with the code in the query string parameters
+ this.lighthouseApi.redirectWithDesktopCode(sourceType, codeData)
})
});
}
diff --git a/frontend/src/app/pages/resource-creator/resource-creator.component.html b/frontend/src/app/pages/resource-creator/resource-creator.component.html
index ef898dfa..201e4152 100644
--- a/frontend/src/app/pages/resource-creator/resource-creator.component.html
+++ b/frontend/src/app/pages/resource-creator/resource-creator.component.html
@@ -28,7 +28,9 @@
Condition
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
+
+ A condition is a disease, illness, or injury that needs to be managed over time. A condition may be a comorbidity (a co-occurring condition), or it may be a main diagnosis.
+