begin restoring Sqlite functionality. (#11)
This commit is contained in:
parent
139b483435
commit
e360369706
|
@ -13,7 +13,6 @@ Fasten is made up of a handful of different components. Here's a summary of the
|
|||
|
||||
**Backend**
|
||||
- Go `v1.18.3`
|
||||
- CouchDB `v3.2`
|
||||
|
||||
**Misc**
|
||||
- Docker `v20.10.17`
|
||||
|
@ -65,6 +64,8 @@ web:
|
|||
src:
|
||||
frontend:
|
||||
path: ./dist
|
||||
database:
|
||||
location: 'fasten.db'
|
||||
log:
|
||||
file: '' #absolute or relative paths allowed, eg. web.log
|
||||
level: INFO
|
||||
|
@ -79,10 +80,6 @@ cd frontend
|
|||
npm run dist -- -c sandbox
|
||||
|
||||
# In terminal #2, run the following
|
||||
docker build -t fasten-couchdb -f docker/couchdb/Dockerfile .
|
||||
docker run --rm -it -p 5984:5984 -v `pwd`/.couchdb/data:/opt/couchdb/data -v `pwd`/.couchdb/config:/opt/couchdb/etc/local.d fasten-couchdb
|
||||
|
||||
# In terminal #3, run the following
|
||||
go mod vendor
|
||||
go run backend/cmd/fasten/fasten.go start --config ./config.dev.yaml --debug
|
||||
|
||||
|
@ -95,13 +92,8 @@ Now you can open a browser to `http://localhost:9090` to see the Fasten UI.
|
|||
The following URL's and credentials may be helpful as you're developing
|
||||
|
||||
- http://localhost:9090/web/dashboard - WebUI
|
||||
- http://localhost:9090/database - CouchDB API proxy
|
||||
- http://localhost:5984/_utils/ - CouchDB admin UI
|
||||
|
||||
### Credentials
|
||||
- Couchdb:
|
||||
- username: `admin`
|
||||
- password: `mysecretpassword`
|
||||
- WebUI:
|
||||
- username: `testuser`
|
||||
- password: `testuser`
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
- [x] pagination
|
||||
- [x] references/links
|
||||
- [x] manual sync
|
||||
- fix sources where refresh token is missing (panics)
|
|
@ -0,0 +1,60 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) {
|
||||
log.Printf("ISSUER KEY: " + issuerSigningKey)
|
||||
userClaims := UserRegisteredClaims{
|
||||
FullName: user.FullName,
|
||||
UserId: user.ID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
// In JWT, the expiry time is expressed as unix milliseconds
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "docker-fastenhealth",
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
//FASTEN_JWT_ISSUER_KEY
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims)
|
||||
//token.Header["kid"] = "docker"
|
||||
tokenString, err := token.SignedString([]byte(issuerSigningKey))
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func JwtValidateFastenToken(encryptionKey string, signedToken string) (*UserRegisteredClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(
|
||||
signedToken,
|
||||
&UserRegisteredClaims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
if jwt.SigningMethodHS256 != token.Method {
|
||||
return nil, errors.New("Invalid signing algorithm")
|
||||
}
|
||||
return []byte(encryptionKey), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*UserRegisteredClaims)
|
||||
if !ok {
|
||||
err = errors.New("couldn't parse claims")
|
||||
return nil, err
|
||||
}
|
||||
if claims.ExpiresAt.Unix() < time.Now().Local().Unix() {
|
||||
err = errors.New("token expired")
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package auth
|
||||
|
||||
import "github.com/golang-jwt/jwt/v4"
|
||||
|
||||
type UserRegisteredClaims struct {
|
||||
FullName string `json:"full_name"`
|
||||
UserId string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
|
@ -26,11 +26,7 @@ func (c *configuration) Init() error {
|
|||
c.SetDefault("web.listen.basepath", "")
|
||||
c.SetDefault("web.src.frontend.path", "/opt/fasten/web")
|
||||
|
||||
c.SetDefault("couchdb.scheme", "http")
|
||||
c.SetDefault("couchdb.host", "localhost")
|
||||
c.SetDefault("couchdb.port", "5984")
|
||||
c.SetDefault("couchdb.admin.username", "admin")
|
||||
c.SetDefault("couchdb.admin.password", "mysecretpassword")
|
||||
c.SetDefault("database.location", "/opt/fasten/db/fasten.db") //TODO: should be /opt/fasten/fasten.db
|
||||
|
||||
c.SetDefault("jwt.issuer.key", "thisismysupersecuressessionsecretlength")
|
||||
|
||||
|
|
|
@ -1,122 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/go-kivik/couchdb/v3"
|
||||
_ "github.com/go-kivik/couchdb/v3" // The CouchDB driver
|
||||
"github.com/go-kivik/kivik/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"log"
|
||||
)
|
||||
|
||||
func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) (DatabaseRepository, error) {
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Couchdb setup
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", appConfig.GetString("couchdb.scheme"), appConfig.GetString("couchdb.host"), appConfig.GetString("couchdb.port"))
|
||||
|
||||
globalLogger.Infof("Trying to connect to couchdb: %s\n", couchdbUrl)
|
||||
|
||||
database, err := kivik.New("couch", couchdbUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to connect to database! - %v", err)
|
||||
}
|
||||
|
||||
err = database.Authenticate(context.Background(),
|
||||
couchdb.BasicAuth(
|
||||
appConfig.GetString("couchdb.admin.username"),
|
||||
appConfig.GetString("couchdb.admin.password")),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to authenticate to database! - %v", err)
|
||||
}
|
||||
globalLogger.Infof("Successfully connected to couchdb: %s\n", couchdbUrl)
|
||||
|
||||
deviceRepo := couchdbRepository{
|
||||
appConfig: appConfig,
|
||||
logger: globalLogger,
|
||||
client: database,
|
||||
}
|
||||
return &deviceRepo, nil
|
||||
}
|
||||
|
||||
type couchdbRepository struct {
|
||||
appConfig config.Interface
|
||||
logger logrus.FieldLogger
|
||||
|
||||
client *kivik.Client
|
||||
}
|
||||
|
||||
type couchDbUser struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Roles []string `json:"roles"`
|
||||
Password string `json:"password"`
|
||||
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
func (cr *couchdbRepository) CreateUser(ctx context.Context, user *models.User) error {
|
||||
|
||||
newUser := &couchDbUser{
|
||||
ID: fmt.Sprintf("%s%s", kivik.UserPrefix, user.Username),
|
||||
Name: user.Username,
|
||||
Type: "user",
|
||||
Roles: []string{},
|
||||
Password: user.Password,
|
||||
|
||||
FullName: user.FullName,
|
||||
}
|
||||
db := cr.client.DB(ctx, "_users")
|
||||
_, err := db.Put(ctx, newUser.ID, newUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//TODO: we should create an index for this database now
|
||||
//db.CreateIndex(ctx, )
|
||||
return err
|
||||
}
|
||||
|
||||
func (cr *couchdbRepository) VerifyUser(ctx context.Context, user *models.User) error {
|
||||
|
||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", cr.appConfig.GetString("couchdb.scheme"), cr.appConfig.GetString("couchdb.host"), cr.appConfig.GetString("couchdb.port"))
|
||||
|
||||
userDatabase, err := kivik.New("couch", couchdbUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to database! - %v", err)
|
||||
}
|
||||
|
||||
err = userDatabase.Authenticate(context.Background(),
|
||||
couchdb.BasicAuth(user.Username, user.Password),
|
||||
)
|
||||
session, err := userDatabase.Session(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("SESSION INFO: %v", session)
|
||||
//TODO: return session info
|
||||
|
||||
//lookup the user in the user database using admin creds
|
||||
adminDb := cr.client.DB(ctx, "_users")
|
||||
userRow := adminDb.Get(ctx, kivik.UserPrefix+session.Name)
|
||||
userDoc := map[string]interface{}{}
|
||||
err = userRow.ScanDoc(&userDoc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if userFullName, hasUserFullName := userDoc["full_name"]; hasUserFullName {
|
||||
user.FullName = userFullName.(string)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *couchdbRepository) Close() error {
|
||||
return nil
|
||||
}
|
|
@ -2,6 +2,7 @@ package database
|
|||
|
||||
import (
|
||||
"context"
|
||||
sourcePkg "github.com/fastenhealth/fasten-sources/clients/models"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
)
|
||||
|
||||
|
@ -10,5 +11,24 @@ type DatabaseRepository interface {
|
|||
Close() error
|
||||
|
||||
CreateUser(context.Context, *models.User) error
|
||||
VerifyUser(context.Context, *models.User) error
|
||||
|
||||
GetUserByEmail(context.Context, string) (*models.User, error)
|
||||
GetCurrentUser(context.Context) *models.User
|
||||
|
||||
GetSummary(ctx context.Context) (*models.Summary, error)
|
||||
|
||||
GetResourceBySourceType(context.Context, string, string) (*models.ResourceFhir, error)
|
||||
GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error)
|
||||
ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error)
|
||||
GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
|
||||
//UpsertProfile(context.Context, *models.Profile) error
|
||||
//UpsertOrganziation(context.Context, *models.Organization) error
|
||||
|
||||
CreateSource(context.Context, *models.SourceCredential) error
|
||||
GetSource(context.Context, string) (*models.SourceCredential, error)
|
||||
GetSourceSummary(context.Context, string) (*models.SourceSummary, error)
|
||||
GetSources(context.Context) ([]models.SourceCredential, error)
|
||||
|
||||
//used by Client
|
||||
UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: interface.go
|
||||
// SourceCredential: interface.go
|
||||
|
||||
// Package mock_database is a generated GoMock package.
|
||||
package mock_database
|
||||
|
|
|
@ -0,0 +1,442 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
sourceModel "github.com/fastenhealth/fasten-sources/clients/models"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) (DatabaseRepository, error) {
|
||||
//backgroundContext := context.Background()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Gorm/SQLite setup
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
globalLogger.Infof("Trying to connect to sqlite db: %s\n", appConfig.GetString("database.location"))
|
||||
|
||||
// When a transaction cannot lock the database, because it is already locked by another one,
|
||||
// SQLite by default throws an error: database is locked. This behavior is usually not appropriate when
|
||||
// concurrent access is needed, typically when multiple processes write to the same database.
|
||||
// PRAGMA busy_timeout lets you set a timeout or a handler for these events. When setting a timeout,
|
||||
// SQLite will try the transaction multiple times within this timeout.
|
||||
// fixes #341
|
||||
// https://rsqlite.r-dbi.org/reference/sqlitesetbusyhandler
|
||||
// retrying for 30000 milliseconds, 30seconds - this would be unreasonable for a distributed multi-tenant application,
|
||||
// but should be fine for local usage.
|
||||
pragmaStr := sqlitePragmaString(map[string]string{
|
||||
"busy_timeout": "30000",
|
||||
"foreign_keys": "ON",
|
||||
})
|
||||
database, err := gorm.Open(sqlite.Open(appConfig.GetString("database.location")+pragmaStr), &gorm.Config{
|
||||
//TODO: figure out how to log database queries again.
|
||||
//Logger: logger
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
|
||||
if strings.ToUpper(appConfig.GetString("log.level")) == "DEBUG" {
|
||||
database = database.Debug() //set debug globally
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to connect to database! - %v", err)
|
||||
}
|
||||
globalLogger.Infof("Successfully connected to fasten sqlite db: %s\n", appConfig.GetString("database.location"))
|
||||
|
||||
//TODO: automigrate for now
|
||||
err = database.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.SourceCredential{},
|
||||
&models.ResourceFhir{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to automigrate! - %v", err)
|
||||
}
|
||||
|
||||
// create/update admin user
|
||||
adminUser := models.User{}
|
||||
err = database.FirstOrCreate(&adminUser, models.User{Username: "admin"}).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to create admin user! - %v", err)
|
||||
}
|
||||
|
||||
deviceRepo := sqliteRepository{
|
||||
appConfig: appConfig,
|
||||
logger: globalLogger,
|
||||
gormClient: database,
|
||||
}
|
||||
return &deviceRepo, nil
|
||||
}
|
||||
|
||||
type sqliteRepository struct {
|
||||
appConfig config.Interface
|
||||
logger logrus.FieldLogger
|
||||
|
||||
gormClient *gorm.DB
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// User
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (sr *sqliteRepository) CreateUser(ctx context.Context, user *models.User) error {
|
||||
if err := user.HashPassword(user.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
record := sr.gormClient.Create(user)
|
||||
if record.Error != nil {
|
||||
return record.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (sr *sqliteRepository) GetUserByEmail(ctx context.Context, username string) (*models.User, error) {
|
||||
var foundUser models.User
|
||||
result := sr.gormClient.Where(models.User{Username: username}).First(&foundUser)
|
||||
return &foundUser, result.Error
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetCurrentUser(ctx context.Context) *models.User {
|
||||
ginCtx := ctx.(*gin.Context)
|
||||
var currentUser models.User
|
||||
sr.gormClient.First(¤tUser, models.User{Username: ginCtx.MustGet("AUTH_USERNAME").(string)})
|
||||
|
||||
return ¤tUser
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// User
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (sr *sqliteRepository) GetSummary(ctx context.Context) (*models.Summary, error) {
|
||||
|
||||
// we want a count of all resources for this user by type
|
||||
var resourceCountResults []map[string]interface{}
|
||||
|
||||
//group by resource type and return counts
|
||||
// SELECT source_resource_type as resource_type, COUNT(*) as count FROM resource_fhirs WHERE source_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9" GROUP BY source_resource_type;
|
||||
result := sr.gormClient.WithContext(ctx).
|
||||
Model(models.ResourceFhir{}).
|
||||
Select("source_id, source_resource_type as resource_type, count(*) as count").
|
||||
Group("source_resource_type").
|
||||
Where(models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
}).
|
||||
Scan(&resourceCountResults)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
// we want a list of all sources (when they were last updated)
|
||||
sources, err := sr.GetSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we want the main Patient for each source
|
||||
patients, err := sr.GetPatientForSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resourceCountResults == nil {
|
||||
resourceCountResults = []map[string]interface{}{}
|
||||
}
|
||||
summary := &models.Summary{
|
||||
Sources: sources,
|
||||
ResourceTypeCounts: resourceCountResults,
|
||||
Patients: patients,
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Resource
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (sr *sqliteRepository) UpsertRawResource(ctx context.Context, sourceCredential sourceModel.SourceCredential, rawResource sourceModel.RawResourceFhir) (bool, error) {
|
||||
|
||||
source := sourceCredential.(models.SourceCredential)
|
||||
|
||||
wrappedResourceModel := &models.ResourceFhir{
|
||||
OriginBase: models.OriginBase{
|
||||
ModelBase: models.ModelBase{},
|
||||
UserID: source.UserID,
|
||||
SourceID: source.ID,
|
||||
SourceResourceID: rawResource.SourceResourceID,
|
||||
SourceResourceType: rawResource.SourceResourceType,
|
||||
},
|
||||
ResourceRaw: datatypes.JSON(rawResource.ResourceRaw),
|
||||
}
|
||||
|
||||
sr.logger.Infof("insert/update (%v) %v", rawResource.SourceResourceType, rawResource.SourceResourceID)
|
||||
|
||||
createResult := sr.gormClient.WithContext(ctx).Where(models.OriginBase{
|
||||
SourceID: wrappedResourceModel.GetSourceID(),
|
||||
SourceResourceID: wrappedResourceModel.GetSourceResourceID(),
|
||||
SourceResourceType: wrappedResourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||
}).FirstOrCreate(wrappedResourceModel)
|
||||
|
||||
if createResult.Error != nil {
|
||||
return false, createResult.Error
|
||||
} else if createResult.RowsAffected == 0 {
|
||||
//at this point, wrappedResourceModel contains the data found in the database.
|
||||
// check if the database resource matches the new resource.
|
||||
if wrappedResourceModel.ResourceRaw.String() != string(rawResource.ResourceRaw) {
|
||||
updateResult := createResult.Updates(wrappedResourceModel)
|
||||
return updateResult.RowsAffected > 0, updateResult.Error
|
||||
} else {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
} else {
|
||||
//resource was created
|
||||
return createResult.RowsAffected > 0, createResult.Error
|
||||
}
|
||||
|
||||
//return results.RowsAffected > 0, results.Error
|
||||
|
||||
//if sr.gormClient.Debug().WithContext(ctx).
|
||||
// Where(models.OriginBase{
|
||||
// SourceID: wrappedResourceModel.GetSourceID(),
|
||||
// SourceResourceID: wrappedResourceModel.GetSourceResourceID(),
|
||||
// SourceResourceType: wrappedResourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||
// }).Updates(wrappedResourceModel).RowsAffected == 0 {
|
||||
// sr.logger.Infof("resource does not exist, creating: %s %s %s", wrappedResourceModel.GetSourceID(), wrappedResourceModel.GetSourceResourceID(), wrappedResourceModel.GetSourceResourceType())
|
||||
// return sr.gormClient.Debug().Create(wrappedResourceModel).Error
|
||||
//}
|
||||
//return nil
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) UpsertResource(ctx context.Context, resourceModel *models.ResourceFhir) error {
|
||||
sr.logger.Infof("insert/update (%T) %v", resourceModel, resourceModel)
|
||||
|
||||
if sr.gormClient.WithContext(ctx).
|
||||
Where(models.OriginBase{
|
||||
SourceID: resourceModel.GetSourceID(),
|
||||
SourceResourceID: resourceModel.GetSourceResourceID(),
|
||||
SourceResourceType: resourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||
}).Updates(resourceModel).RowsAffected == 0 {
|
||||
sr.logger.Infof("resource does not exist, creating: %s %s %s", resourceModel.GetSourceID(), resourceModel.GetSourceResourceID(), resourceModel.GetSourceResourceType())
|
||||
return sr.gormClient.Create(resourceModel).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) ListResources(ctx context.Context, queryOptions models.ListResourceQueryOptions) ([]models.ResourceFhir, error) {
|
||||
|
||||
queryParam := models.ResourceFhir{
|
||||
OriginBase: models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
},
|
||||
}
|
||||
|
||||
if len(queryOptions.SourceResourceType) > 0 {
|
||||
queryParam.OriginBase.SourceResourceType = queryOptions.SourceResourceType
|
||||
}
|
||||
|
||||
if len(queryOptions.SourceID) > 0 {
|
||||
sourceUUID, err := uuid.Parse(queryOptions.SourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryParam.OriginBase.SourceID = sourceUUID
|
||||
}
|
||||
|
||||
manifestJson, _ := json.MarshalIndent(queryParam, "", " ")
|
||||
sr.logger.Infof("THE QUERY OBJECT===========> %v", string(manifestJson))
|
||||
|
||||
var wrappedResourceModels []models.ResourceFhir
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(queryParam).
|
||||
Find(&wrappedResourceModels)
|
||||
|
||||
return wrappedResourceModels, results.Error
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetResourceBySourceType(ctx context.Context, sourceResourceType string, sourceResourceId string) (*models.ResourceFhir, error) {
|
||||
queryParam := models.ResourceFhir{
|
||||
OriginBase: models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
SourceResourceType: sourceResourceType,
|
||||
SourceResourceID: sourceResourceId,
|
||||
},
|
||||
}
|
||||
|
||||
var wrappedResourceModel models.ResourceFhir
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(queryParam).
|
||||
First(&wrappedResourceModel)
|
||||
|
||||
return &wrappedResourceModel, results.Error
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetResourceBySourceId(ctx context.Context, sourceId string, sourceResourceId string) (*models.ResourceFhir, error) {
|
||||
sourceIdUUID, err := uuid.Parse(sourceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryParam := models.ResourceFhir{
|
||||
OriginBase: models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
SourceID: sourceIdUUID,
|
||||
SourceResourceID: sourceResourceId,
|
||||
},
|
||||
}
|
||||
|
||||
var wrappedResourceModel models.ResourceFhir
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(queryParam).
|
||||
First(&wrappedResourceModel)
|
||||
|
||||
return &wrappedResourceModel, results.Error
|
||||
}
|
||||
|
||||
// Get the patient for each source (for the current user)
|
||||
func (sr *sqliteRepository) GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error) {
|
||||
|
||||
//SELECT * FROM resource_fhirs WHERE user_id = "" and source_resource_type = "Patient" GROUP BY source_id
|
||||
|
||||
//var sourceCred models.SourceCredential
|
||||
//results := sr.gormClient.WithContext(ctx).
|
||||
// Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID, ModelBase: models.ModelBase{ID: sourceUUID}}).
|
||||
// First(&sourceCred)
|
||||
|
||||
var wrappedResourceModels []models.ResourceFhir
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Model(models.ResourceFhir{}).
|
||||
Group("source_id").
|
||||
Where(models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
SourceResourceType: "Patient",
|
||||
}).
|
||||
Find(&wrappedResourceModels)
|
||||
|
||||
return wrappedResourceModels, results.Error
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// SourceCredential
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (sr *sqliteRepository) CreateSource(ctx context.Context, sourceCreds *models.SourceCredential) error {
|
||||
sourceCreds.UserID = sr.GetCurrentUser(ctx).ID
|
||||
|
||||
//Assign will **always** update the source credential in the DB with data passed into this function.
|
||||
return sr.gormClient.WithContext(ctx).
|
||||
Where(models.SourceCredential{
|
||||
UserID: sourceCreds.UserID,
|
||||
SourceType: sourceCreds.SourceType,
|
||||
Patient: sourceCreds.Patient}).
|
||||
Assign(*sourceCreds).FirstOrCreate(sourceCreds).Error
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetSource(ctx context.Context, sourceId string) (*models.SourceCredential, error) {
|
||||
sourceUUID, err := uuid.Parse(sourceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sourceCred models.SourceCredential
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID, ModelBase: models.ModelBase{ID: sourceUUID}}).
|
||||
First(&sourceCred)
|
||||
|
||||
return &sourceCred, results.Error
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetSourceSummary(ctx context.Context, sourceId string) (*models.SourceSummary, error) {
|
||||
sourceUUID, err := uuid.Parse(sourceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceSummary := &models.SourceSummary{}
|
||||
|
||||
source, err := sr.GetSource(ctx, sourceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceSummary.Source = source
|
||||
|
||||
//group by resource type and return counts
|
||||
// SELECT source_resource_type as resource_type, COUNT(*) as count FROM resource_fhirs WHERE source_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9" GROUP BY source_resource_type;
|
||||
|
||||
var resourceTypeCounts []map[string]interface{}
|
||||
|
||||
result := sr.gormClient.WithContext(ctx).
|
||||
Model(models.ResourceFhir{}).
|
||||
Select("source_id, source_resource_type as resource_type, count(*) as count").
|
||||
Group("source_resource_type").
|
||||
Where(models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
SourceID: sourceUUID,
|
||||
}).
|
||||
Scan(&resourceTypeCounts)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
sourceSummary.ResourceTypeCounts = resourceTypeCounts
|
||||
|
||||
//set patient
|
||||
var wrappedPatientResourceModel models.ResourceFhir
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(models.OriginBase{
|
||||
UserID: sr.GetCurrentUser(ctx).ID,
|
||||
SourceResourceType: "Patient",
|
||||
SourceID: sourceUUID,
|
||||
}).
|
||||
First(&wrappedPatientResourceModel)
|
||||
|
||||
if results.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
sourceSummary.Patient = &wrappedPatientResourceModel
|
||||
|
||||
return sourceSummary, nil
|
||||
}
|
||||
|
||||
func (sr *sqliteRepository) GetSources(ctx context.Context) ([]models.SourceCredential, error) {
|
||||
|
||||
var sourceCreds []models.SourceCredential
|
||||
results := sr.gormClient.WithContext(ctx).
|
||||
Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID}).
|
||||
Find(&sourceCreds)
|
||||
|
||||
return sourceCreds, results.Error
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Utilities
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func sqlitePragmaString(pragmas map[string]string) string {
|
||||
q := url.Values{}
|
||||
for key, val := range pragmas {
|
||||
q.Add("_pragma", key+"="+val)
|
||||
}
|
||||
|
||||
queryStr := q.Encode()
|
||||
if len(queryStr) > 0 {
|
||||
return "?" + queryStr
|
||||
}
|
||||
return ""
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ModelBase struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
||||
}
|
||||
|
||||
//https://medium.com/@the.hasham.ali/how-to-use-uuid-key-type-with-gorm-cc00d4ec7100
|
||||
|
||||
func (base *ModelBase) BeforeCreate(tx *gorm.DB) error {
|
||||
base.ID = uuid.New()
|
||||
return nil
|
||||
}
|
||||
|
||||
type OriginBase struct {
|
||||
ModelBase
|
||||
User *User `json:"user,omitempty" gorm:"-"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
|
||||
Source *SourceCredential `json:"source,omitempty" gorm:"-"`
|
||||
SourceID uuid.UUID `json:"source_id" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||
|
||||
SourceResourceType string `json:"source_resource_type" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||
SourceResourceID string `json:"source_resource_id" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||
}
|
||||
|
||||
func (o OriginBase) GetSourceID() uuid.UUID {
|
||||
return o.SourceID
|
||||
}
|
||||
|
||||
func (o OriginBase) GetSourceResourceType() string {
|
||||
return o.SourceResourceType
|
||||
}
|
||||
func (o OriginBase) GetSourceResourceID() string {
|
||||
return o.SourceResourceID
|
||||
}
|
||||
|
||||
type OriginBaser interface {
|
||||
GetSourceID() uuid.UUID
|
||||
GetSourceResourceType() string
|
||||
GetSourceResourceID() string
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type ResourceFhir struct {
|
||||
OriginBase
|
||||
|
||||
//embedded data
|
||||
ResourceRaw datatypes.JSON `json:"resource_raw" gorm:"resource_raw"`
|
||||
}
|
||||
|
||||
type ListResourceQueryOptions struct {
|
||||
SourceID string
|
||||
SourceResourceType string
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"github.com/fastenhealth/fasten-sources/pkg"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SourceCredential Data/Medical Provider Credentials
|
||||
// similar to LighthouseSourceDefinition from fasten-source
|
||||
type SourceCredential struct {
|
||||
ModelBase
|
||||
User User `json:"user,omitempty"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||
SourceType pkg.SourceType `json:"source_type" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||
Patient string `json:"patient" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||
|
||||
//oauth endpoints
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
|
||||
Scopes []string `json:"scopes_supported" gorm:"type:text;serializer:json"`
|
||||
Issuer string `json:"issuer"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported" gorm:"type:text;serializer:json"`
|
||||
ResponseType []string `json:"response_types_supported" gorm:"type:text;serializer:json"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported" gorm:"type:text;serializer:json"`
|
||||
Audience string `json:"aud"` //optional - required for some providers
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported" gorm:"type:text;serializer:json"`
|
||||
|
||||
//Fasten custom configuration
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"` //optional - supported by some providers, not others.
|
||||
ApiEndpointBaseUrl string `json:"api_endpoint_base_url"` //api endpoint we'll communicate with after authentication
|
||||
ClientId string `json:"client_id"`
|
||||
RedirectUri string `json:"redirect_uri"` //lighthouse url the provider will redirect to (registered with App)
|
||||
|
||||
Confidential bool `json:"confidential"` //if enabled, requires client_secret to authenticate with provider (PKCE)
|
||||
CORSRelayRequired bool `json:"cors_relay_required"` //if true, requires CORS proxy/relay, as provider does not return proper response to CORS preflight
|
||||
//SecretKeyPrefix string `json:"-"` //the secret key prefix to use, if empty (default) will use the sourceType value
|
||||
|
||||
// auth/credential data
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IdToken string `json:"id_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetSourceType() pkg.SourceType {
|
||||
return s.SourceType
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetClientId() string {
|
||||
return s.ClientId
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetPatientId() string {
|
||||
return s.Patient
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetOauthAuthorizationEndpoint() string {
|
||||
return s.AuthorizationEndpoint
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetOauthTokenEndpoint() string {
|
||||
return s.TokenEndpoint
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetApiEndpointBaseUrl() string {
|
||||
return s.ApiEndpointBaseUrl
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetRefreshToken() string {
|
||||
return s.RefreshToken
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetAccessToken() string {
|
||||
return s.AccessToken
|
||||
}
|
||||
|
||||
func (s SourceCredential) GetExpiresAt() int64 {
|
||||
return s.ExpiresAt
|
||||
}
|
||||
|
||||
func (s SourceCredential) RefreshTokens(accessToken string, refreshToken string, expiresAt int64) {
|
||||
if accessToken != s.AccessToken {
|
||||
// update the "source" credential with new data (which will need to be sent
|
||||
s.AccessToken = accessToken
|
||||
s.ExpiresAt = expiresAt
|
||||
// Don't overwrite `RefreshToken` with an empty value
|
||||
// if this was a token refreshing request.
|
||||
if refreshToken != "" {
|
||||
s.RefreshToken = refreshToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
serverUrl: connectData.message.api_endpoint_base_url,
|
||||
clientId: connectData.message.client_id,
|
||||
redirectUri: connectData.message.redirect_uri,
|
||||
tokenUri: `${connectData.message.oauth_endpoint_base_url}/token`,
|
||||
scope: connectData.message.scopes.join(' '),
|
||||
tokenResponse: payload,
|
||||
expiresAt: getAccessTokenExpiration(payload, new BrowserAdapter()),
|
||||
codeChallenge: codeChallenge,
|
||||
codeVerifier: codeVerifier
|
||||
|
||||
*/
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type SourceSummary struct {
|
||||
Source *SourceCredential `json:"source,omitempty"`
|
||||
ResourceTypeCounts []map[string]interface{} `json:"resource_type_counts,omitempty"`
|
||||
Patient *ResourceFhir `json:"patient"`
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type Summary struct {
|
||||
Sources []SourceCredential `json:"sources"`
|
||||
Patients []ResourceFhir `json:"patients"`
|
||||
ResourceTypeCounts []map[string]interface{} `json:"resource_type_counts"`
|
||||
}
|
|
@ -1,7 +1,26 @@
|
|||
package models
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
type User struct {
|
||||
ModelBase
|
||||
FullName string `json:"full_name"`
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username" gorm:"unique"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (user *User) HashPassword(password string) error {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Password = string(bytes)
|
||||
return nil
|
||||
}
|
||||
func (user *User) CheckPassword(providedPassword string) error {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(providedPassword))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/auth"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AuthSignup(c *gin.Context) {
|
||||
|
@ -27,7 +26,7 @@ func AuthSignup(c *gin.Context) {
|
|||
}
|
||||
|
||||
//TODO: we can derive the encryption key and the hash'ed user from the responseData sub. For now the Sub will be the user id prepended with hello.
|
||||
userFastenToken, err := jwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key"))
|
||||
userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken})
|
||||
}
|
||||
|
@ -41,43 +40,21 @@ func AuthSignin(c *gin.Context) {
|
|||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := databaseRepo.VerifyUser(c, &user)
|
||||
|
||||
foundUser, err := databaseRepo.GetUserByEmail(c, user.Username)
|
||||
if err != nil || foundUser == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Sprintf("could not find user: %s", user.Username)})
|
||||
return
|
||||
}
|
||||
|
||||
err = foundUser.CheckPassword(user.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": fmt.Sprintf("username or password does not match: %s", user.Username)})
|
||||
return
|
||||
}
|
||||
|
||||
//TODO: we can derive the encryption key and the hash'ed user from the responseData sub. For now the Sub will be the user id prepended with hello.
|
||||
userFastenToken, err := jwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key"))
|
||||
userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken})
|
||||
}
|
||||
|
||||
type UserRegisteredClaims struct {
|
||||
FullName string `json:"full_name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func jwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) {
|
||||
log.Printf("ISSUER KEY: " + issuerSigningKey)
|
||||
userClaims := UserRegisteredClaims{
|
||||
FullName: user.FullName,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
// In JWT, the expiry time is expressed as unix milliseconds
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "docker-fastenhealth",
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
//FASTEN_JWT_ISSUER_KEY
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims)
|
||||
//token.Header["kid"] = "docker"
|
||||
tokenString, err := token.SignedString([]byte(issuerSigningKey))
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CouchDBProxy(c *gin.Context) {
|
||||
appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||
|
||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", appConfig.GetString("couchdb.scheme"), appConfig.GetString("couchdb.host"), appConfig.GetString("couchdb.port"))
|
||||
remote, err := url.Parse(couchdbUrl)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(remote)
|
||||
//Define the director func
|
||||
//This is a good place to log, for example
|
||||
proxy.Director = func(req *http.Request) {
|
||||
req.Header = c.Request.Header
|
||||
req.Header.Add("X-Forwarded-Host", req.Host)
|
||||
req.Header.Add("X-Origin-Host", remote.Host)
|
||||
req.Host = remote.Host
|
||||
req.URL.Scheme = remote.Scheme
|
||||
req.URL.Host = remote.Host
|
||||
log.Printf(c.Param("proxyPath"))
|
||||
req.URL.Path = strings.TrimPrefix(c.Param("proxyPath"), "/database")
|
||||
|
||||
//todo: throw an error if not a user DB.
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ListResourceFhir(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
listResourceQueryOptions := models.ListResourceQueryOptions{}
|
||||
if len(c.Query("sourceResourceType")) > 0 {
|
||||
listResourceQueryOptions.SourceResourceType = c.Query("sourceResourceType")
|
||||
}
|
||||
if len(c.Query("sourceID")) > 0 {
|
||||
listResourceQueryOptions.SourceID = c.Query("sourceID")
|
||||
}
|
||||
|
||||
wrappedResourceModels, err := databaseRepo.ListResources(c, listResourceQueryOptions)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving resources", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": wrappedResourceModels})
|
||||
}
|
||||
|
||||
//this endpoint retrieves a specific resource by its ID
|
||||
func GetResourceFhir(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
resourceId := strings.Trim(c.Param("resourceId"), "/")
|
||||
sourceId := strings.Trim(c.Param("sourceId"), "/")
|
||||
wrappedResourceModel, err := databaseRepo.GetResourceBySourceId(c, sourceId, resourceId)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving resource", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": wrappedResourceModel})
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fastenhealth/fasten-sources/clients/factory"
|
||||
sourceModels "github.com/fastenhealth/fasten-sources/clients/models"
|
||||
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CreateSource(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
sourceCred := models.SourceCredential{}
|
||||
if err := c.ShouldBindJSON(&sourceCred); err != nil {
|
||||
logger.Errorln("An error occurred while parsing posted source credential", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("Parsed Create SourceCredential Credentials Payload: %v", sourceCred)
|
||||
|
||||
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
|
||||
summary, err := syncSourceResources(c, logger, databaseRepo, sourceCred)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "source": sourceCred, "data": summary})
|
||||
}
|
||||
|
||||
func SourceSync(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
logger.Infof("Get SourceCredential Credentials: %v", c.Param("sourceId"))
|
||||
|
||||
sourceCred, err := databaseRepo.GetSource(c, c.Param("sourceId"))
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving source credential", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
// after creating the source, we should do a bulk import
|
||||
summary, err := syncSourceResources(c, logger, databaseRepo, *sourceCred)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "source": sourceCred, "data": summary})
|
||||
}
|
||||
|
||||
func CreateManualSource(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
// single file
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not extract file from form"})
|
||||
return
|
||||
}
|
||||
fmt.Printf("Uploaded filename: %s", file.Filename)
|
||||
|
||||
// create a temporary file to store this uploaded file
|
||||
bundleFile, err := ioutil.TempFile("", file.Filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not create temp file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Upload the file to specific bundleFile.
|
||||
err = c.SaveUploadedFile(file, bundleFile.Name())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not save temp file"})
|
||||
return
|
||||
}
|
||||
|
||||
// We cannot save the "SourceCredential" object yet, as we do not know the patientID
|
||||
|
||||
// create a "manual" client, which we can use to parse the
|
||||
manualSourceCredential := models.SourceCredential{
|
||||
SourceType: sourcePkg.SourceTypeManual,
|
||||
}
|
||||
tempSourceClient, _, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourcePkg.SourceTypeManual, c, logger, manualSourceCredential)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while initializing hub client using manual source without credentials", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
patientId, bundleType, err := tempSourceClient.ExtractPatientId(bundleFile)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while extracting patient id", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
manualSourceCredential.Patient = patientId
|
||||
|
||||
//store the manualSourceCredential
|
||||
err = databaseRepo.CreateSource(c, &manualSourceCredential)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while creating manual source", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
manualSourceClient, _, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourcePkg.SourceTypeManual, c, logger, manualSourceCredential)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while initializing hub client using manual source with credential", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := manualSourceClient.SyncAllBundle(databaseRepo, bundleFile, bundleType)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while processing bundle", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
|
||||
}
|
||||
|
||||
func GetSource(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
sourceCred, err := databaseRepo.GetSource(c, c.Param("sourceId"))
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving source credential", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCred})
|
||||
}
|
||||
|
||||
func GetSourceSummary(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
sourceSummary, err := databaseRepo.GetSourceSummary(c, c.Param("sourceId"))
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving source summary", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceSummary})
|
||||
}
|
||||
|
||||
func ListSource(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
sourceCreds, err := databaseRepo.GetSources(c)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while listing source credentials", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
////// private functions
|
||||
func syncSourceResources(c *gin.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)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while initializing hub client using source credential", err)
|
||||
return sourceModels.UpsertSummary{}, err
|
||||
}
|
||||
//TODO: update source
|
||||
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.Errorln("An error occurred while updating source credential", err)
|
||||
// return err
|
||||
//}
|
||||
}
|
||||
|
||||
summary, err := sourceClient.SyncAll(databaseRepo)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while bulk import of resources from source", err)
|
||||
return summary, err
|
||||
}
|
||||
return summary, nil
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetSummary(c *gin.Context) {
|
||||
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||
|
||||
summary, err := databaseRepo.GetSummary(c)
|
||||
if err != nil {
|
||||
logger.Errorln("An error occurred while retrieving summary", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/auth"
|
||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
authHeaderParts := strings.Split(authHeader, " ")
|
||||
|
||||
if len(authHeaderParts) != 2 {
|
||||
log.Println("Authentication header is invalid: " + authHeader)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "request does not contain a valid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := authHeaderParts[1]
|
||||
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "request does not contain an access token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claim, err := auth.JwtValidateFastenToken(appConfig.GetString("jwt.issuer.key"), tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
//todo, is this shared between all sessions??
|
||||
c.Set("AUTH_TOKEN", tokenString)
|
||||
c.Set("AUTH_USERNAME", claim.Subject)
|
||||
c.Set("AUTH_USERID", claim.UserId)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
|
@ -42,10 +42,32 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
|
|||
|
||||
api.POST("/auth/signup", handler.AuthSignup)
|
||||
api.POST("/auth/signin", handler.AuthSignin)
|
||||
//
|
||||
//r.Any("/database/*proxyPath", handler.CouchDBProxy)
|
||||
//r.GET("/cors/*proxyPath", handler.CORSProxy)
|
||||
//r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
|
||||
|
||||
r.Any("/database/*proxyPath", handler.CouchDBProxy)
|
||||
r.GET("/cors/*proxyPath", handler.CORSProxy)
|
||||
r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
|
||||
secure := api.Group("/secure").Use(middleware.RequireAuth())
|
||||
{
|
||||
secure.GET("/summary", handler.GetSummary)
|
||||
|
||||
secure.POST("/source", handler.CreateSource)
|
||||
secure.POST("/source/manual", handler.CreateManualSource)
|
||||
secure.GET("/source", handler.ListSource)
|
||||
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/:sourceId/:resourceId", handler.GetResourceFhir)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
//http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992
|
||||
api.GET("/raw/:username/:sourceId/*path", handler.RawRequestSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,4 +22,4 @@ web:
|
|||
|
||||
log:
|
||||
file: '' #absolute or relative paths allowed, eg. web.log
|
||||
level: INFO
|
||||
level: DEBUG
|
||||
|
|
|
@ -34,8 +34,7 @@
|
|||
],
|
||||
"scripts": [
|
||||
"node_modules/@panva/oauth4webapi/build/index.js"
|
||||
],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"prod": {
|
||||
|
@ -161,8 +160,7 @@
|
|||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
|
|
|
@ -8,9 +8,9 @@ import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.c
|
|||
import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component';
|
||||
import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component';
|
||||
import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard';
|
||||
import {EncryptionEnabledAuthGuard} from './auth-guards/encryption-enabled.auth-guard';
|
||||
import {SourceDetailComponent} from './pages/source-detail/source-detail.component';
|
||||
import {EncryptionManagerComponent} from './pages/encryption-manager/encryption-manager.component';
|
||||
import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component';
|
||||
import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component';
|
||||
|
||||
const routes: Routes = [
|
||||
|
||||
|
@ -20,14 +20,15 @@ const routes: Routes = [
|
|||
{ path: 'auth/signup/callback/:idp_type', component: AuthSignupComponent },
|
||||
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'resource/:resource_id', component: ResourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
{ path: 'source/:source_id/resource/:resource_id', component: ResourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
|
||||
{ path: 'account/security/manager', component: EncryptionManagerComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
|
||||
{ path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
{ path: 'medical-history', component: MedicalHistoryComponent, 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) },
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Component, OnInit } from '@angular/core';
|
|||
import {NavigationEnd, Router} from '@angular/router';
|
||||
import {fromWorker} from 'observable-webworker';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {QueueService} from './workers/queue.service';
|
||||
import {ToastService} from './services/toast.service';
|
||||
|
||||
@Component({
|
||||
|
@ -17,7 +16,7 @@ export class AppComponent implements OnInit {
|
|||
showHeader:boolean = false;
|
||||
showFooter:boolean = true;
|
||||
|
||||
constructor(private router: Router, private queueService: QueueService, private toastService: ToastService) {}
|
||||
constructor(private router: Router, private toastService: ToastService) {}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
|
|
|
@ -19,15 +19,15 @@ import { AuthSigninComponent } from './pages/auth-signin/auth-signin.component';
|
|||
import { FormsModule } from '@angular/forms';
|
||||
import { NgxDropzoneModule } from 'ngx-dropzone';
|
||||
import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard';
|
||||
import { EncryptionEnabledAuthGuard } from './auth-guards/encryption-enabled.auth-guard';
|
||||
import {FastenDbService} from './services/fasten-db.service';
|
||||
import {FastenApiService} from './services/fasten-api.service';
|
||||
import {Router} from '@angular/router';
|
||||
import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
|
||||
import { HighlightModule, HIGHLIGHT_OPTIONS } from 'ngx-highlightjs';
|
||||
import {AuthInterceptorService} from './services/auth-interceptor.service';
|
||||
import { MomentModule } from 'ngx-moment';
|
||||
import { EncryptionManagerComponent } from './pages/encryption-manager/encryption-manager.component';
|
||||
import {AuthService} from './services/auth.service';
|
||||
import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component';
|
||||
import { MedicalHistoryComponent } from './pages/medical-history/medical-history.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -40,7 +40,8 @@ import {AuthService} from './services/auth.service';
|
|||
AuthSignupComponent,
|
||||
AuthSigninComponent,
|
||||
SourceDetailComponent,
|
||||
EncryptionManagerComponent,
|
||||
PatientProfileComponent,
|
||||
MedicalHistoryComponent,
|
||||
],
|
||||
imports: [
|
||||
FormsModule,
|
||||
|
@ -63,7 +64,6 @@ import {AuthService} from './services/auth.service';
|
|||
deps: [AuthService, Router]
|
||||
},
|
||||
IsAuthenticatedAuthGuard,
|
||||
EncryptionEnabledAuthGuard,
|
||||
{
|
||||
provide: HIGHLIGHT_OPTIONS,
|
||||
useValue: {
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router} from '@angular/router';
|
||||
import {FastenDbService} from '../services/fasten-db.service';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionEnabledAuthGuard implements CanActivate {
|
||||
constructor(private fastenDbService: FastenDbService, private router: Router) {
|
||||
|
||||
}
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise <boolean> {
|
||||
//check if the user has encryption data stored in this browser already
|
||||
if (!await this.fastenDbService.isCryptConfigAvailable()) {
|
||||
return await this.router.navigate(['/account/security/manager']);
|
||||
}
|
||||
// continue as normal
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import { Router } from '@angular/router';
|
||||
import {AuthService} from '../../services/auth.service';
|
||||
import {UserRegisteredClaims} from '../../models/fasten/user-registered-claims';
|
||||
|
|
|
@ -9,6 +9,6 @@
|
|||
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" *ngFor="let resource of resourceList">
|
||||
<a routerLink="/resource/{{resource.base64Id()}}">{{resource.source_resource_id}}</a>
|
||||
<a routerLink="/source/{{resource.source_id}}/resource/{{resource.source_resource_id}}">{{resource.source_resource_id}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
import {ResourceListComponentInterface} from '../list-generic-resource/list-generic-resource.component';
|
||||
import {Router} from '@angular/router';
|
||||
import {Base64} from '../../../lib/utils/base64';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-fallback-resource',
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import {ChangeDetectorRef, Component, Input, OnInit, ViewChild} from '@angular/core';
|
||||
import {DatatableComponent, ColumnMode, SelectionType} from '@swimlane/ngx-datatable';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
import {FORMATTERS, getPath, obsValue, attributeXTime} from './utils';
|
||||
import {Router} from '@angular/router';
|
||||
import {Base64} from '../../../lib/utils/base64';
|
||||
|
||||
//all Resource list components must implement this Interface
|
||||
export interface ResourceListComponentInterface {
|
||||
|
@ -61,7 +60,6 @@ export class ListGenericResourceComponent implements OnInit, ResourceListCompone
|
|||
|
||||
this.rows = this.resourceList.map((resource) => {
|
||||
let row = {
|
||||
_id: resource._id,
|
||||
source_id: resource.source_id,
|
||||
source_resource_type: resource.source_resource_type,
|
||||
source_resource_id: resource.source_resource_id
|
||||
|
@ -87,7 +85,8 @@ export class ListGenericResourceComponent implements OnInit, ResourceListCompone
|
|||
*/
|
||||
onSelect({ selected }) {
|
||||
console.log('Select Event', selected);
|
||||
this.router.navigateByUrl(`/resource/${Base64.Encode(selected[0]._id)}`);
|
||||
this.router.navigateByUrl(`/source/${selected[0].source_id}/resource/${selected[0].source_resource_id}`);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges, Type, ViewChild} from '@angular/core';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {Source} from '../../../lib/models/database/source';
|
||||
import {FastenApiService} from '../../services/fasten-api.service';
|
||||
import {Source} from '../../models/fasten/source';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
import {ListAdverseEventComponent} from '../list-generic-resource/list-adverse-event.component';
|
||||
import {ListCommunicationComponent} from '../list-generic-resource/list-communication.component';
|
||||
import {ListConditionComponent} from '../list-generic-resource/list-condition.component';
|
||||
|
@ -49,7 +49,7 @@ export class ResourceListComponent implements OnInit, OnChanges {
|
|||
@ViewChild(ResourceListOutletDirective, {static: true}) resourceListOutlet!: ResourceListOutletDirective;
|
||||
|
||||
|
||||
constructor(private fastenDb: FastenDbService) { }
|
||||
constructor(private fastenApi: FastenApiService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadComponent()
|
||||
|
@ -63,7 +63,7 @@ export class ResourceListComponent implements OnInit, OnChanges {
|
|||
const viewContainerRef = this.resourceListOutlet.viewContainerRef;
|
||||
viewContainerRef.clear();
|
||||
|
||||
this.getResources().then((resourceList) => {
|
||||
this.getResources().subscribe((resourceList) => {
|
||||
let componentType = this.typeLookup(this.resourceListType)
|
||||
if(componentType != null){
|
||||
console.log("Attempting to create component", this.resourceListType, componentType)
|
||||
|
@ -75,19 +75,18 @@ export class ResourceListComponent implements OnInit, OnChanges {
|
|||
})
|
||||
}
|
||||
|
||||
getResources(): Promise<ResourceFhir[]>{
|
||||
getResources(): Observable<ResourceFhir[]>{
|
||||
|
||||
if(this.resourceListType && !this.resourceListCache[this.resourceListType]){
|
||||
// this resource type list has not been downloaded yet, do so now
|
||||
return this.fastenDb.GetResourcesForSource(this.source._id, this.resourceListType)
|
||||
.then((paginatedResponse) => {
|
||||
let resourceList = paginatedResponse.rows as ResourceFhir[]
|
||||
return this.fastenApi.getResources(this.resourceListType, this.source.id)
|
||||
.pipe(map((resourceList: ResourceFhir[]) => {
|
||||
//cache this response so we can skip the request next time
|
||||
this.resourceListCache[this.resourceListType] = resourceList
|
||||
return resourceList
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
return Promise.resolve(this.resourceListCache[this.resourceListType] || [])
|
||||
return of(this.resourceListCache[this.resourceListType] || [])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
export class ResourceFhir {
|
||||
user_id?: string
|
||||
source_id: string = ""
|
||||
source_resource_type: string = ""
|
||||
source_resource_id: string = ""
|
||||
|
||||
fhir_version: string = ""
|
||||
resource_raw: IResourceRaw
|
||||
|
||||
constructor(object?: any) {
|
||||
return Object.assign(this, object)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//This is the "raw" Fhir resource
|
||||
export interface IResourceRaw {
|
||||
resourceType: string
|
||||
id?: string
|
||||
meta?: IResourceMetaRaw
|
||||
}
|
||||
// This is the "raw" Fhir Bundle resource
|
||||
export interface IResourceBundleRaw {
|
||||
resourceType: string
|
||||
id?: string
|
||||
entry: IResourceBundleEntryRaw[]
|
||||
total?: number
|
||||
link?: IResourceBundleLinkRaw[]
|
||||
meta?: IResourceMetaRaw
|
||||
}
|
||||
|
||||
export interface IResourceBundleLinkRaw {
|
||||
id?: string
|
||||
relation: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface IResourceBundleEntryRaw {
|
||||
id?: string
|
||||
fullUrl?: string
|
||||
resource: IResourceRaw
|
||||
}
|
||||
|
||||
export interface IResourceMetaRaw {
|
||||
id?: string
|
||||
versionId?: string
|
||||
lastUpdated: string
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import {Source} from '../database/source';
|
||||
import {ResourceFhir} from '../database/resource_fhir';
|
||||
import {Source} from './source';
|
||||
import {ResourceFhir} from './resource_fhir';
|
||||
|
||||
export class ResourceTypeCounts {
|
||||
count: number
|
|
@ -0,0 +1,18 @@
|
|||
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
||||
|
||||
export class Source extends LighthouseSourceMetadata{
|
||||
id?: string
|
||||
user_id?: number
|
||||
source_type: string
|
||||
|
||||
patient: string
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
expires_at: number //seconds since epoch
|
||||
|
||||
constructor(object: any) {
|
||||
super()
|
||||
return Object.assign(this, object)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import {Source} from '../database/source';
|
||||
import {Source} from './source';
|
||||
import {ResourceTypeCounts} from './source-summary';
|
||||
import {ResourceFhir} from '../database/resource_fhir';
|
||||
import {ResourceFhir} from './resource_fhir';
|
||||
|
||||
export class Summary {
|
||||
sources: Source[]
|
|
@ -1,4 +1,5 @@
|
|||
export class User {
|
||||
user_id?: number
|
||||
full_name?: string
|
||||
username?: string
|
||||
password?: string
|
|
@ -1,4 +1,4 @@
|
|||
import {Source} from '../../../lib/models/database/source';
|
||||
import {Source} from '../../models/fasten/source';
|
||||
|
||||
export class SourceSyncMessage {
|
||||
source: Source
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import {Component, OnInit} from '@angular/core';
|
||||
import {User} from '../../../lib/models/fasten/user';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {User} from '../../models/fasten/user';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {ToastService} from '../../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||
import {environment} from '../../../environments/environment';
|
||||
import {AuthService} from '../../services/auth.service';
|
||||
import {Location} from '@angular/common';
|
||||
import {PouchdbCrypto} from '../../../lib/database/plugins/crypto';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-signin',
|
||||
|
@ -23,7 +21,6 @@ export class AuthSigninComponent implements OnInit {
|
|||
loading: boolean = false
|
||||
|
||||
constructor(
|
||||
private fastenDb: FastenDbService,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
|
@ -47,10 +44,7 @@ export class AuthSigninComponent implements OnInit {
|
|||
//TODO: replace Pouchdb.
|
||||
let userId = this.authService.GetCurrentUser().sub
|
||||
//TODO: static IV, must be removed/replaced.
|
||||
return {username: userId, key: userId, config: "WyI3NUhJcEhZTXBNVXRtMHlJcnBMckhRPT0iLHsic2FsdExlbmd0aCI6MTYsIm1lbW9yeVNpemUiOjQwOTYsIml0ZXJhdGlvbnMiOjEwMCwicGFyYWxsZWxpc20iOjF9XQ=="}
|
||||
})
|
||||
.then((cryptoConfig) => {
|
||||
PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
return {username: userId, key: userId}
|
||||
})
|
||||
.then(() => this.router.navigateByUrl('/dashboard'))
|
||||
.catch((err)=>{
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {User} from '../../../lib/models/fasten/user';
|
||||
import {User} from '../../models/fasten/user';
|
||||
import {Router} from '@angular/router';
|
||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||
import {ToastService} from '../../services/toast.service';
|
||||
|
|
|
@ -148,7 +148,7 @@
|
|||
<div class="media-body">
|
||||
<h5>{{metadataSource[source.source_type]?.display}}</h5>
|
||||
<p>
|
||||
{{getPatientSummary(patientForSource[source._id]?.resource_raw)}}
|
||||
{{getPatientSummary(patientForSource[source.id]?.resource_raw)}}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import {Source} from '../../../lib/models/database/source';
|
||||
import {Source} from '../../models/fasten/source';
|
||||
import {Router} from '@angular/router';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
import {forkJoin} from 'rxjs';
|
||||
import {MetadataSource} from '../../models/fasten/metadata-source';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {Summary} from '../../../lib/models/fasten/summary';
|
||||
import {Base64} from '../../../lib/utils/base64';
|
||||
import {FastenApiService} from '../../services/fasten-api.service';
|
||||
import {Summary} from '../../models/fasten/summary';
|
||||
import {LighthouseService} from '../../services/lighthouse.service';
|
||||
|
||||
@Component({
|
||||
|
@ -25,7 +24,7 @@ export class DashboardComponent implements OnInit {
|
|||
|
||||
constructor(
|
||||
private lighthouseApi: LighthouseService,
|
||||
private fastenDb: FastenDbService,
|
||||
private fastenApi: FastenApiService,
|
||||
private router: Router
|
||||
) { }
|
||||
|
||||
|
@ -49,7 +48,7 @@ export class DashboardComponent implements OnInit {
|
|||
// })
|
||||
// })
|
||||
|
||||
forkJoin([this.fastenDb.GetSummary(), this.lighthouseApi.getLighthouseSourceMetadataMap()]).subscribe(results => {
|
||||
forkJoin([this.fastenApi.getSummary(), this.lighthouseApi.getLighthouseSourceMetadataMap()]).subscribe(results => {
|
||||
let summary = results[0] as Summary
|
||||
let metadataSource = results[1] as { [name: string]: MetadataSource }
|
||||
|
||||
|
@ -81,7 +80,7 @@ export class DashboardComponent implements OnInit {
|
|||
}
|
||||
|
||||
selectSource(selectedSource: Source){
|
||||
this.router.navigateByUrl(`/source/${Base64.Encode(selectedSource._id)}`, {
|
||||
this.router.navigateByUrl(`/source/${selectedSource.id}`, {
|
||||
state: selectedSource
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,151 +0,0 @@
|
|||
<div class="az-content az-content-profile">
|
||||
<div class="container">
|
||||
<div class="row w-100">
|
||||
<div class="col-sm-12">
|
||||
<h2 class="az-content-title mg-t-20">Security Manager</h2>
|
||||
<p class="mb-5">
|
||||
Before you use Fasten you'll need to import or generate a new encryption key.
|
||||
<br/>
|
||||
<br/>
|
||||
Fasten uses <a href="https://en.wikipedia.org/wiki/Zero-knowledge_service" target="_blank">zero-knowledge encryption</a> to secure your medical data.
|
||||
This means that your medical records are encrypted on your device, before they are stored in the Fasten database.
|
||||
The encrypted data stored in the database is worthless without your encryption key.
|
||||
<br/>
|
||||
<br/>
|
||||
You must safely store your encryption key as you would a username & password,
|
||||
as it's the only thing that will allow you to access your records.</p>
|
||||
|
||||
<div *ngIf="cryptoPanel == CryptoPanelType.Generate" class="wizard clearfix">
|
||||
<div class="steps clearfix">
|
||||
<ul role="tablist">
|
||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
||||
<a aria-controls="wizard1-p-0">
|
||||
<span class="number">1</span>
|
||||
<span class="title">Download</span></a>
|
||||
</li>
|
||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
||||
<a aria-controls="wizard1-p-2">
|
||||
<span class="number">2</span>
|
||||
<span class="title">Validate</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="content clearfix">
|
||||
<ng-container [ngSwitch]="currentStep">
|
||||
<ng-container *ngSwitchCase="1">
|
||||
<h3 tabindex="-1" class="title current">Generate an encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-0">
|
||||
Fasten has generated an encryption key for you. You can use this encryption key to decode your medical records on this browser, and other devices.
|
||||
<br/>
|
||||
|
||||
This is the only time the encryption key will be available to view, copy or download. We recommend downloading this key and storing the file in a secure location.
|
||||
<br/>
|
||||
You can reset your encryption key at any time, however any previously encrypted data will no longer be accessible.
|
||||
</p>
|
||||
|
||||
<pre><code [highlight]="currentCryptoConfig | json"></code></pre>
|
||||
|
||||
<div class="row row-xs wd-xl-80p">
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<a [href]="generateCryptoConfigUrl" [download]="generateCryptoConfigFilename" class="btn btn-warning btn-rounded btn-block">Download Encryption Key</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="2">
|
||||
<h3 tabindex="-1" class="title current">Validate your encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Please select your encryption key (which you generated in the previous step) using the file input below.
|
||||
<br/>
|
||||
It'll be validated against your browser's encryption key to ensure fidelity.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="generateCustomFile" (change)="generateOpenFileHandler($event.target.files)" accept="application/json">
|
||||
<label class="custom-file-label" for="generateCustomFile">Choose file</label>
|
||||
<div *ngIf="generateCustomFileError" class="alert alert-danger">
|
||||
{{generateCustomFileError}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="currentStep != lastStep" class="actions clearfix">
|
||||
<ul role="menu" aria-label="Pagination">
|
||||
<li class="disabled" aria-disabled="true"></li>
|
||||
<li aria-disabled="false"><button class="btn btn-az-primary btn-block" (click)="nextHandler()" role="menuitem">Next</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="cryptoPanel == CryptoPanelType.Import" class="wizard clearfix">
|
||||
<div class="steps clearfix">
|
||||
<ul role="tablist">
|
||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
||||
<a aria-controls="wizard1-p-0">
|
||||
<span class="number">1</span>
|
||||
<span class="title">Import</span></a>
|
||||
</li>
|
||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
||||
<a aria-controls="wizard1-p-2">
|
||||
<span class="number">2</span>
|
||||
<span class="title">Validate</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="content clearfix">
|
||||
<ng-container [ngSwitch]="currentStep">
|
||||
<ng-container *ngSwitchCase="1">
|
||||
<h3 tabindex="-1" class="title current">Import existing encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Fasten was unable to find your encryption key on this device, and has detected encrypted data in your database.
|
||||
<br/>
|
||||
You will need to provide your encryption key to access your health records.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="importCustomFile" (change)="importOpenFileHandler($event.target.files)" accept="application/json">
|
||||
<label class="custom-file-label" for="importCustomFile">Choose file</label>
|
||||
<div *ngIf="importCustomFileError" class="alert alert-danger">
|
||||
{{importCustomFileError}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="2">
|
||||
<h3 tabindex="-1" class="title current">Validate encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Thank you for providing your encryption key. Fasten will attempt to decrypt your secured records with this key.
|
||||
</p>
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -1,26 +0,0 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EncryptionManagerComponent } from './encryption-manager.component';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('EncryptionManagerComponent', () => {
|
||||
let component: EncryptionManagerComponent;
|
||||
let fixture: ComponentFixture<EncryptionManagerComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ EncryptionManagerComponent ],
|
||||
imports: [HttpClientTestingModule],
|
||||
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EncryptionManagerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -1,198 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {PouchdbCryptConfig, PouchdbCrypto} from '../../../lib/database/plugins/crypto';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
|
||||
import {Router} from '@angular/router';
|
||||
import {ToastService} from '../../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||
|
||||
export enum CryptoPanelType {
|
||||
Loading,
|
||||
Import,
|
||||
Generate,
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-encryption-manager',
|
||||
templateUrl: './encryption-manager.component.html',
|
||||
styleUrls: ['./encryption-manager.component.scss']
|
||||
})
|
||||
export class EncryptionManagerComponent implements OnInit {
|
||||
CryptoPanelType = CryptoPanelType
|
||||
cryptoPanel: CryptoPanelType = CryptoPanelType.Loading
|
||||
currentCryptoConfig: PouchdbCryptConfig = null
|
||||
|
||||
generateCryptoConfigUrl: SafeResourceUrl = ""
|
||||
generateCryptoConfigFilename: string = ""
|
||||
generateCustomFileError: string = ""
|
||||
|
||||
importCustomFileError: string = ""
|
||||
|
||||
currentStep: number
|
||||
lastStep: number
|
||||
|
||||
|
||||
constructor(private fastenDbService: FastenDbService, private sanitizer: DomSanitizer, private router: Router, private toastService: ToastService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.fastenDbService.IsDatabasePopulated()
|
||||
.then((isPopulated) => {
|
||||
if(isPopulated){
|
||||
return this.showImportCryptoConfig()
|
||||
} else {
|
||||
return this.showGenerateCryptoConfig()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
nextHandler() {
|
||||
this.currentStep += 1
|
||||
// if (!this.stepsService.isLastStep()) {
|
||||
// this.stepsService.moveToNextStep();
|
||||
// } else {
|
||||
// this.onSubmit();
|
||||
// }
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Generate Wizard Methods
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
async showGenerateCryptoConfig(): Promise<PouchdbCryptConfig> {
|
||||
this.cryptoPanel = CryptoPanelType.Generate
|
||||
this.currentStep = 1
|
||||
this.lastStep = 2
|
||||
if(!this.currentCryptoConfig){
|
||||
this.currentCryptoConfig = await PouchdbCrypto.CryptConfig(uuidv4(), this.fastenDbService.current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(this.currentCryptoConfig) //store in indexdb
|
||||
|
||||
|
||||
//generate URL for downloading.
|
||||
let currentCryptoConfigBlob = new Blob([JSON.stringify(this.currentCryptoConfig)], { type: 'application/json' });
|
||||
this.generateCryptoConfigUrl = this.sanitizer.bypassSecurityTrustResourceUrl(window.URL.createObjectURL(currentCryptoConfigBlob));
|
||||
this.generateCryptoConfigFilename = `fasten-${this.fastenDbService.current_user}.key.json`
|
||||
}
|
||||
|
||||
return this.currentCryptoConfig
|
||||
}
|
||||
|
||||
generateOpenFileHandler(fileList: FileList) {
|
||||
this.generateCustomFileError = ""
|
||||
let file = fileList[0];
|
||||
this.readFileContent(file)
|
||||
.then((content) => {
|
||||
let parsedCryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
||||
|
||||
//check if the parsed encryption key matches the currently set encryption key
|
||||
|
||||
if(parsedCryptoConfig.key == this.currentCryptoConfig.key &&
|
||||
parsedCryptoConfig.username == this.currentCryptoConfig.username &&
|
||||
parsedCryptoConfig.config == this.currentCryptoConfig.config){
|
||||
return true
|
||||
} else {
|
||||
//throw an error & notify user
|
||||
this.generateCustomFileError = "Crypto configuration file does not match"
|
||||
throw new Error(this.generateCustomFileError)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = "Successfully validated & stored encryption key."
|
||||
toastNotification.autohide = true
|
||||
this.toastService.show(toastNotification)
|
||||
|
||||
//redirect user to dashboard
|
||||
return this.router.navigate(['/dashboard']);
|
||||
})
|
||||
.catch((err) => {
|
||||
// delete invalid encryption key
|
||||
this.currentStep = 1
|
||||
return PouchdbCrypto.DeleteCryptConfig(this.fastenDbService.current_user)
|
||||
.then(() => {
|
||||
//an error occurred while importing credential
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Error
|
||||
toastNotification.message = "Provided encryption key does not match. Generating new encryption key, please store it and try again."
|
||||
toastNotification.autohide = false
|
||||
this.toastService.show(toastNotification)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Import Wizard Methods
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
async showImportCryptoConfig(): Promise<any> {
|
||||
this.cryptoPanel = CryptoPanelType.Import
|
||||
this.currentStep = 1
|
||||
this.lastStep = 2
|
||||
}
|
||||
|
||||
importOpenFileHandler(fileList: FileList) {
|
||||
this.importCustomFileError = ""
|
||||
let file = fileList[0];
|
||||
this.readFileContent(file)
|
||||
.then((content) => {
|
||||
let cryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
||||
|
||||
if(!cryptoConfig.key || !cryptoConfig.config){
|
||||
this.importCustomFileError = "Invalid crypto configuration file"
|
||||
throw new Error(this.importCustomFileError)
|
||||
}
|
||||
|
||||
return PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
})
|
||||
.then(() => {
|
||||
//go to step 2
|
||||
this.currentStep = 2
|
||||
//attempt to initialize pouchdb with specified crypto
|
||||
this.fastenDbService.ResetDB()
|
||||
return this.fastenDbService.GetSources()
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = "Successfully validated & imported encryption key."
|
||||
toastNotification.autohide = true
|
||||
this.toastService.show(toastNotification)
|
||||
|
||||
return this.router.navigate(['/dashboard']);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
// delete invalid encryption key
|
||||
this.currentStep = 1
|
||||
return PouchdbCrypto.DeleteCryptConfig(this.fastenDbService.current_user)
|
||||
.then(() => {
|
||||
//an error occurred while importing credential
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Error
|
||||
toastNotification.message = "Provided encryption key does not match. Please try a different key"
|
||||
toastNotification.autohide = false
|
||||
this.toastService.show(toastNotification)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private readFileContent(file: File): Promise<string>{
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!file) {
|
||||
resolve('');
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = reader.result.toString();
|
||||
resolve(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
<div class="az-content">
|
||||
<div class="container">
|
||||
<div class="az-content-body">
|
||||
|
||||
<!-- Header Row -->
|
||||
<div class="row">
|
||||
<div class="col-6 bg-indigo tx-white d-flex align-items-center">
|
||||
<h1>Medical History</h1>
|
||||
</div>
|
||||
<div class="col-3 mt-2 mb-2 tx-12">
|
||||
<p class="mb-0">
|
||||
<strong>Patient:</strong> Caldwell, Ruben<br/>
|
||||
<strong>Address:</strong> 123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||
<strong>Date of Birth:</strong> June 20, 1929<br/>
|
||||
<strong>Phone:</strong> 415-343-2342<br/>
|
||||
<strong>Email:</strong> myemail@gmail.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-3 tx-indigo mt-2 mb-2 tx-12">
|
||||
<p class="mb-0">
|
||||
<strong>Primary Care:</strong> Bishop, J. ANRP<br/>
|
||||
<strong>Address:</strong> Malcom Randall VA <br/>Medical Center Gainsville FL<br/>
|
||||
<strong>Phone:</strong> 123-321-5532<br/>
|
||||
<strong>Email:</strong> myemail@va.com<br/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions Title -->
|
||||
<div class="row mt-5 mb-3">
|
||||
<div class="col-6">
|
||||
<h1>Condition</h1>
|
||||
</div>
|
||||
<div class="col-6 tx-indigo">
|
||||
<h1>History</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Condition List -->
|
||||
|
||||
<div class="row bg-indigo tx-white pt-1 pb-1">
|
||||
<!-- Condition Header -->
|
||||
<div class="col-6 d-flex align-items-center">
|
||||
<h3 class="mb-0">Gout</h3>
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center">
|
||||
<h3 class="mb-0">Nov 16, 2002 - Present</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<!-- Condition Details -->
|
||||
|
||||
<div class="col-6 mb-2">
|
||||
|
||||
<div class="row pl-3">
|
||||
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||
<h5>Involved in Care</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong>James Bishop</strong>, ANRP
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Primary Care
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong>Matthew Leonard</strong>, MD
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Diagnosing Physician
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong>Stephanie Wrenn</strong>, MD
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Dietitian
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||
<h5>Initial Presentation</h5>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
Acute right knee pain and tenderness around the joint line - this was likely caused by acute renal failure.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-6 mb-2 bg-gray-100">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||
<h5>Nov 19, 2012</h5>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<strong>Medications:</strong> Colchicine, as needed for gout attacks
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||
<h5>Nov 16, 2012</h5>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<strong>Procedures:</strong> The fluid in your right knee was drained.
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<strong>Tests and Examinations:</strong> The fluid tested prositive for gout crystals
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<strong>Medications:</strong> You were given a steroid injection to reduce inflammation and as short course prednistone to reduce pain and inflammation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MedicalHistoryComponent } from './medical-history.component';
|
||||
|
||||
describe('MedicalHistoryComponent', () => {
|
||||
let component: MedicalHistoryComponent;
|
||||
let fixture: ComponentFixture<MedicalHistoryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ MedicalHistoryComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MedicalHistoryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-medical-history',
|
||||
templateUrl: './medical-history.component.html',
|
||||
styleUrls: ['./medical-history.component.scss']
|
||||
})
|
||||
export class MedicalHistoryComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
|
@ -30,7 +30,7 @@
|
|||
<div *ngFor="let sourceInfo of connectedSourceList" class="col-sm-3 mg-b-20 px-3">
|
||||
<div class="card h-100 d-flex align-items-center justify-content-center p-3 rounded-0 cursor-pointer">
|
||||
<div (click)="openModal(contentModalRef, sourceInfo)" class="card-body">
|
||||
<img [src]="'assets/sources/'+sourceInfo?.metadata['source_type']+'.png'" alt="client" class="img-fluid">
|
||||
<img [src]="'assets/sources/'+sourceInfo?.metadata['source_type']+'.png'" [alt]="sourceInfo?.metadata.display" class="img-fluid">
|
||||
<div *ngIf="status[sourceInfo.source?.source_type]" class="progress">
|
||||
<div [style.width]="status[sourceInfo?.source?.source_type] == 'authorize' ? '33%' : '66%'" class="bg-indigo progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div>
|
||||
</div>
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import {Component, OnInit} from '@angular/core';
|
||||
import {LighthouseService} from '../../services/lighthouse.service';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {LighthouseSourceMetadata} from '../../../lib/models/lighthouse/lighthouse-source-metadata';
|
||||
import {Source} from '../../../lib/models/database/source';
|
||||
import {FastenApiService} from '../../services/fasten-api.service';
|
||||
import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-source-metadata';
|
||||
import {Source} from '../../models/fasten/source';
|
||||
import {getAccessTokenExpiration, jwtDecode} from 'fhirclient/lib/lib';
|
||||
import BrowserAdapter from 'fhirclient/lib/adapters/BrowserAdapter';
|
||||
import {MetadataSource} from '../../models/fasten/metadata-source';
|
||||
import {ModalDismissReasons, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {Location} from '@angular/common';
|
||||
import {SourceType} from '../../../lib/models/database/source_types';
|
||||
import {QueueService} from '../../workers/queue.service';
|
||||
import {ToastService} from '../../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||
import {SourceSyncMessage} from '../../models/queue/source-sync-message';
|
||||
import {UpsertSummary} from '../../../lib/models/fasten/upsert-summary';
|
||||
import {environment} from '../../../environments/environment';
|
||||
// If you dont import this angular will import the wrong "Location"
|
||||
|
||||
|
@ -35,15 +32,15 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
|
||||
constructor(
|
||||
private lighthouseApi: LighthouseService,
|
||||
private fastenDb: FastenDbService,
|
||||
private fastenApi: FastenApiService,
|
||||
private modalService: NgbModal,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private queueService: QueueService,
|
||||
private toastService: ToastService
|
||||
) { }
|
||||
|
||||
|
||||
environment_name = environment.environment_name
|
||||
status: { [name: string]: string } = {}
|
||||
|
||||
|
@ -66,9 +63,9 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
this.callback(callbackSourceType).then(console.log)
|
||||
}
|
||||
|
||||
this.fastenDb.GetSources()
|
||||
.then((paginatedList) => {
|
||||
const sourceList = paginatedList.rows as Source[]
|
||||
this.fastenApi.getSources()
|
||||
.subscribe((paginatedList: Source[]) => {
|
||||
const sourceList = paginatedList as Source[]
|
||||
|
||||
for (const sourceType in this.metadataSources) {
|
||||
let isConnected = false
|
||||
|
@ -167,7 +164,7 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
//Create FHIR Client
|
||||
|
||||
const dbSourceCredential = new Source({
|
||||
source_type: sourceType as SourceType,
|
||||
source_type: sourceType,
|
||||
|
||||
authorization_endpoint: sourceMetadata.authorization_endpoint,
|
||||
token_endpoint: sourceMetadata.token_endpoint,
|
||||
|
@ -194,9 +191,44 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())),
|
||||
})
|
||||
|
||||
await this.fastenDb.UpsertSource(dbSourceCredential).then(console.log)
|
||||
this.queueSourceSyncWorker(sourceType as SourceType, dbSourceCredential)
|
||||
this.fastenApi.createSource(dbSourceCredential)
|
||||
.subscribe((msg) => {
|
||||
// const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
|
||||
delete this.status[sourceType]
|
||||
// window.location.reload();
|
||||
|
||||
console.log("source sync-all response:", msg)
|
||||
//remove item from available sources list, add to connected sources.
|
||||
this.availableSourceList.splice(this.availableSourceList.findIndex((item) => item.metadata.source_type == sourceType), 1);
|
||||
if(this.connectedSourceList.findIndex((item) => item.metadata.source_type == sourceType) == -1){
|
||||
//only add this as a connected source if its "new"
|
||||
this.connectedSourceList.push({source: msg, metadata: this.metadataSources[sourceType]})
|
||||
}
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = `Successfully connected ${sourceType}`
|
||||
|
||||
// const upsertSummary = sourceSyncMessage.response as UpsertSummary
|
||||
// if(upsertSummary && upsertSummary.totalResources != upsertSummary.updatedResources.length){
|
||||
// toastNotification.message += `\n (total: ${upsertSummary.totalResources}, updated: ${upsertSummary.updatedResources.length})`
|
||||
// } else if(upsertSummary){
|
||||
// toastNotification.message += `\n (total: ${upsertSummary.totalResources})`
|
||||
// }
|
||||
|
||||
this.toastService.show(toastNotification)
|
||||
},
|
||||
(err) => {
|
||||
delete this.status[sourceType]
|
||||
// window.location.reload();
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Error
|
||||
toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}`
|
||||
toastNotification.autohide = false
|
||||
this.toastService.show(toastNotification)
|
||||
console.error(err)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -208,15 +240,15 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
public uploadSourceBundleHandler(event) {
|
||||
this.uploadedFile = [event.addedFiles[0]]
|
||||
//TODO: handle manual bundles.
|
||||
// this.fastenDb.CreateManualSource(event.addedFiles[0]).subscribe(
|
||||
// (respData) => {
|
||||
// console.log("source manual source create response:", respData)
|
||||
// },
|
||||
// (err) => {console.log(err)},
|
||||
// () => {
|
||||
// this.uploadedFile = []
|
||||
// }
|
||||
// )
|
||||
this.fastenApi.createManualSource(event.addedFiles[0]).subscribe(
|
||||
(respData) => {
|
||||
console.log("source manual source create response:", respData)
|
||||
},
|
||||
(err) => {console.log(err)},
|
||||
() => {
|
||||
this.uploadedFile = []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public openModal(contentModalRef, sourceListItem: SourceListItem) {
|
||||
|
@ -234,56 +266,22 @@ export class MedicalSourcesComponent implements OnInit {
|
|||
this.status[source.source_type] = "authorize"
|
||||
this.modalService.dismissAll()
|
||||
|
||||
this.queueSourceSyncWorker(source.source_type as SourceType, source)
|
||||
this.fastenApi.syncSource(source.id).subscribe(
|
||||
(respData) => {
|
||||
delete this.status[source.source_type]
|
||||
console.log("source sync response:", respData)
|
||||
},
|
||||
(err) => {
|
||||
delete this.status[source.source_type]
|
||||
console.log(err)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Private
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
private queueSourceSyncWorker(sourceType: SourceType, source: Source){
|
||||
//this work is pushed to the Sync Worker.
|
||||
//TODO: if the user closes the browser the data update may not complete. we should set a status flag when "starting" sync, and then remove it when compelte
|
||||
// so that we can show incompelte statuses
|
||||
this.queueService.runSourceSyncWorker(source)
|
||||
.subscribe((msg) => {
|
||||
const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
|
||||
delete this.status[sourceType]
|
||||
// window.location.reload();
|
||||
|
||||
console.log("source sync-all response:", sourceSyncMessage)
|
||||
//remove item from available sources list, add to connected sources.
|
||||
this.availableSourceList.splice(this.availableSourceList.findIndex((item) => item.metadata.source_type == sourceType), 1);
|
||||
if(this.connectedSourceList.findIndex((item) => item.metadata.source_type == sourceType) == -1){
|
||||
//only add this as a connected source if its "new"
|
||||
this.connectedSourceList.push({source: sourceSyncMessage.source, metadata: this.metadataSources[sourceType]})
|
||||
}
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = `Successfully connected ${sourceType}`
|
||||
|
||||
const upsertSummary = sourceSyncMessage.response as UpsertSummary
|
||||
if(upsertSummary && upsertSummary.totalResources != upsertSummary.updatedResources.length){
|
||||
toastNotification.message += `\n (total: ${upsertSummary.totalResources}, updated: ${upsertSummary.updatedResources.length})`
|
||||
} else if(upsertSummary){
|
||||
toastNotification.message += `\n (total: ${upsertSummary.totalResources})`
|
||||
}
|
||||
|
||||
this.toastService.show(toastNotification)
|
||||
},
|
||||
(err) => {
|
||||
delete this.status[sourceType]
|
||||
// window.location.reload();
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Error
|
||||
toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}`
|
||||
toastNotification.autohide = false
|
||||
this.toastService.show(toastNotification)
|
||||
console.error(err)
|
||||
});
|
||||
}
|
||||
|
||||
private getDismissReason(reason: any): string {
|
||||
if (reason === ModalDismissReasons.ESC) {
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
<div class="az-content">
|
||||
<div class="container">
|
||||
<div class="az-content-body">
|
||||
|
||||
<!-- Header Row -->
|
||||
<div class="row">
|
||||
<div class="col-6 bg-indigo tx-white d-flex align-items-center">
|
||||
<h1>Patient Profile</h1>
|
||||
</div>
|
||||
<div class="col-3 mt-2 mb-2 tx-12">
|
||||
<p class="mb-0">
|
||||
<strong>Patient:</strong> Caldwell, Ruben<br/>
|
||||
<strong>Address:</strong> 123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||
<strong>Date of Birth:</strong> June 20, 1929<br/>
|
||||
<strong>Phone:</strong> 415-343-2342<br/>
|
||||
<strong>Email:</strong> myemail@gmail.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-3 tx-indigo mt-2 mb-2 tx-12">
|
||||
<p class="mb-0">
|
||||
<strong>Primary Care:</strong> Bishop, J. ANRP<br/>
|
||||
<strong>Address:</strong> Malcom Randall VA <br/>Medical Center Gainsville FL<br/>
|
||||
<strong>Phone:</strong> 123-321-5532<br/>
|
||||
<strong>Email:</strong> myemail@va.com<br/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-3 pr-3">
|
||||
<!-- Patient Name Row -->
|
||||
<div class="row mt-5 mb-3">
|
||||
<div class="col-6">
|
||||
<h1>Caldwell, Ruben</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Details -->
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<h2 class="tx-indigo">Patient</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">First Name:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Ruben
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Last Name:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Caldwell
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Gender:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
M
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Martial Status:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Married
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Religious Affil:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
N/A
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Ethnicity:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
White/Caucation
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Language:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
English
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Address:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Date of Birth:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
June 20, 1929
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Phone:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
415-343-2342
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Email:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
myemail@gmail.com
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h2 class="tx-indigo">Care Provider</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Primary Care:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Bishop, J. ANRP
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Address:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
Malcom Randall VA <br/>Medical Center Gainsville FL
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Phone:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
123-321-5532
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<strong class="tx-indigo">Email:</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
myemail@va.com
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Immunizations & Allergies -->
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<h2 class="tx-indigo">Immunizations</h2>
|
||||
|
||||
<p>
|
||||
<strong class="tx-indigo">Phenumovax</strong><br/>
|
||||
07/09/2022<br/>
|
||||
<br/>
|
||||
<strong class="tx-indigo">Hepatitis B Series [complete]</strong><br/>
|
||||
01/12/2005<br/>
|
||||
<br/>
|
||||
<strong class="tx-indigo">Tetanus Toxoid [complete]</strong><br/>
|
||||
02/02/2010<br/>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h2 class="tx-indigo">Allergies</h2>
|
||||
|
||||
<p>
|
||||
<strong class="tx-indigo">Latex</strong><br/>
|
||||
(AV/Historical) with rash and agitation<br/>
|
||||
<br/>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PatientProfileComponent } from './patient-profile.component';
|
||||
|
||||
describe('PatientProfileComponent', () => {
|
||||
let component: PatientProfileComponent;
|
||||
let fixture: ComponentFixture<PatientProfileComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ PatientProfileComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PatientProfileComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-patient-profile',
|
||||
templateUrl: './patient-profile.component.html',
|
||||
styleUrls: ['./patient-profile.component.scss']
|
||||
})
|
||||
export class PatientProfileComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
|
@ -1,186 +0,0 @@
|
|||
|
||||
<div class=" content">
|
||||
<div class=" row">
|
||||
<div class=" col-md-8">
|
||||
<div class=" card">
|
||||
<div class=" card-header"><h5 class=" title">Edit Profile</h5></div>
|
||||
<div class=" card-body">
|
||||
<form>
|
||||
<div class=" row">
|
||||
<div class=" col-md-5 pr-md-1">
|
||||
<div class=" form-group">
|
||||
<label> Company (disabled) </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
disabled=""
|
||||
placeholder="Company"
|
||||
type="text"
|
||||
value="Creative Code Inc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-3 px-md-1">
|
||||
<div class=" form-group">
|
||||
<label> Username </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="Username"
|
||||
type="text"
|
||||
value="michael23"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-4 pl-md-1">
|
||||
<div class=" form-group">
|
||||
<label for="exampleInputEmail1"> Email address </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="mike@email.com"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" row">
|
||||
<div class=" col-md-6 pr-md-1">
|
||||
<div class=" form-group">
|
||||
<label> First Name </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="Company"
|
||||
type="text"
|
||||
value="Mike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-6 pl-md-1">
|
||||
<div class=" form-group">
|
||||
<label> Last Name </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="Last Name"
|
||||
type="text"
|
||||
value="Andrew"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" row">
|
||||
<div class=" col-md-12">
|
||||
<div class=" form-group">
|
||||
<label> Address </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="Home Address"
|
||||
type="text"
|
||||
value="Bld Mihail Kogalniceanu, nr. 8 Bl 1, Sc 1, Ap 09"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" row">
|
||||
<div class=" col-md-4 pr-md-1">
|
||||
<div class=" form-group">
|
||||
<label> City </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="City"
|
||||
type="text"
|
||||
value="Mike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-4 px-md-1">
|
||||
<div class=" form-group">
|
||||
<label> Country </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="Country"
|
||||
type="text"
|
||||
value="Andrew"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-4 pl-md-1">
|
||||
<div class=" form-group">
|
||||
<label> Postal Code </label>
|
||||
<input
|
||||
class=" form-control"
|
||||
placeholder="ZIP Code"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" row">
|
||||
<div class=" col-md-8">
|
||||
<div class=" form-group">
|
||||
<label> About Me </label>
|
||||
<textarea
|
||||
class=" form-control"
|
||||
cols="80"
|
||||
placeholder="Here can be your description"
|
||||
rows="4"
|
||||
value="Mike"
|
||||
>
|
||||
Lamborghini Mercy, Your chick she so thirsty, I'm in that two seat Lambo.
|
||||
</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class=" card-footer">
|
||||
<button class=" btn btn-fill btn-danger" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" col-md-4">
|
||||
<div class=" card card-user">
|
||||
<div class=" card-body">
|
||||
<p class=" card-text"></p>
|
||||
<div class=" author">
|
||||
<div class=" block block-one"></div>
|
||||
<div class=" block block-two"></div>
|
||||
<div class=" block block-three"></div>
|
||||
<div class=" block block-four"></div>
|
||||
<a href="javascript:void(0)">
|
||||
<img alt="..." class=" avatar" src="assets/img/emilyz.jpg" />
|
||||
|
||||
<h5 class=" title">Mike Andrew</h5>
|
||||
</a>
|
||||
<p class=" description">Ceo/Co-Founder</p>
|
||||
</div>
|
||||
<div class=" card-description">
|
||||
Do not be scared of the truth because we need to restart the human
|
||||
foundation in truth And I love you like Kanye loves Kanye I love
|
||||
Rick Owens’ bed design but the back is...
|
||||
</div>
|
||||
</div>
|
||||
<div class=" card-footer">
|
||||
<div class=" button-container">
|
||||
<button
|
||||
class=" btn btn-icon btn-round btn-facebook"
|
||||
href="javascript:void(0)"
|
||||
>
|
||||
<i class=" fab fa-facebook"> </i>
|
||||
</button>
|
||||
<button
|
||||
class=" btn btn-icon btn-round btn-twitter"
|
||||
href="javascript:void(0)"
|
||||
>
|
||||
<i class=" fab fa-twitter"> </i>
|
||||
</button>
|
||||
<button
|
||||
class=" btn btn-icon btn-round btn-google"
|
||||
href="javascript:void(0)"
|
||||
>
|
||||
<i class=" fab fa-google-plus"> </i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,11 +0,0 @@
|
|||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-patient",
|
||||
templateUrl: "patient.component.html"
|
||||
})
|
||||
export class PatientComponent implements OnInit {
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {FastenApiService} from '../../services/fasten-api.service';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {Base64} from '../../../lib/utils/base64';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
|
||||
@Component({
|
||||
selector: 'app-resource-detail',
|
||||
|
@ -14,23 +13,16 @@ export class ResourceDetailComponent implements OnInit {
|
|||
sourceName: string = ""
|
||||
resource: ResourceFhir = null
|
||||
|
||||
constructor(private fastenDb: FastenDbService, private router: Router, private route: ActivatedRoute) {
|
||||
constructor(private fastenApi: FastenApiService, private router: Router, private route: ActivatedRoute) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
//always request the resource by id
|
||||
let resourceId = Base64.Decode(this.route.snapshot.paramMap.get('resource_id'))
|
||||
if (resourceId){
|
||||
this.fastenDb.GetResource(resourceId)
|
||||
.then((resourceFhir) => {
|
||||
this.fastenApi.getResourceBySourceId(this.route.snapshot.paramMap.get('source_id'), this.route.snapshot.paramMap.get('resource_id')).subscribe((resourceFhir) => {
|
||||
console.log("RESOURECE FHIR", resourceFhir)
|
||||
this.resource = resourceFhir;
|
||||
this.sourceId = this.route.snapshot.paramMap.get('source_id')
|
||||
this.sourceName = "unknown" //TODO popualte this
|
||||
});
|
||||
this.sourceId = resourceId.split(":")[1]
|
||||
this.sourceName = Base64.Decode(this.sourceId).split(":")[1]
|
||||
} else {
|
||||
console.log("invalid or missing resource id")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<div class="patient-row row">
|
||||
<div class="col-7 patient-name"><h3 class="pull-left text-primary">{{getPatientName()}}</h3></div>
|
||||
<div class="col-5">
|
||||
<a routerLink="/resource/{{selectedPatient?.base64Id()}}" class="btn btn-indigo btn-icon float-right">
|
||||
<a routerLink="/source/{{selectedSource?.id}}/resource/{{selectedPatient?.source_resource_id}}" class="btn btn-indigo btn-icon float-right">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import {Component, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {Source} from '../../../lib/models/database/source';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
||||
import {Source} from '../../models/fasten/source';
|
||||
import {FastenApiService} from '../../services/fasten-api.service';
|
||||
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||
import {getPath} from '../../components/list-generic-resource/utils';
|
||||
import {Base64} from '../../../lib/utils/base64';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-source-detail',
|
||||
|
@ -20,7 +18,7 @@ export class SourceDetailComponent implements OnInit {
|
|||
|
||||
resourceTypeCounts: { [name: string]: number } = {}
|
||||
|
||||
constructor(private fastenDb: FastenDbService, private router: Router, private route: ActivatedRoute) {
|
||||
constructor(private fastenApi: FastenApiService, private router: Router, private route: ActivatedRoute) {
|
||||
//check if the current Source was sent over using the router state storage:
|
||||
if(this.router.getCurrentNavigation()?.extras?.state){
|
||||
this.selectedSource = this.router.getCurrentNavigation().extras.state as Source
|
||||
|
@ -29,7 +27,7 @@ export class SourceDetailComponent implements OnInit {
|
|||
|
||||
ngOnInit(): void {
|
||||
//always request the source summary
|
||||
this.fastenDb.GetSourceSummary(Base64.Decode(this.route.snapshot.paramMap.get('source_id'))).then((sourceSummary) => {
|
||||
this.fastenApi.getSourceSummary(this.route.snapshot.paramMap.get('source_id')).subscribe((sourceSummary) => {
|
||||
this.selectedSource = sourceSummary.source;
|
||||
this.selectedPatient = sourceSummary.patient;
|
||||
for(let resourceTypeCount of sourceSummary.resource_type_counts){
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Injectable, Injector } from '@angular/core';
|
||||
import {HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
|
||||
import { FastenDbService } from './fasten-db.service';
|
||||
import {Router} from '@angular/router';
|
||||
import {Observable, of, throwError} from 'rxjs';
|
||||
import {catchError} from 'rxjs/operators';
|
||||
|
@ -30,40 +29,61 @@ export class AuthInterceptorService implements HttpInterceptor {
|
|||
}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
let authToken = this.authService.GetAuthToken()
|
||||
if(!authToken){
|
||||
//no authToken available, lets just handle the request as-is
|
||||
return next.handle(req)
|
||||
}
|
||||
|
||||
//only intercept requests to the Fasten API, Database & Lighthouse, all other requests should be sent as-is
|
||||
console.log("Intercepting Request", req)
|
||||
|
||||
//only intercept requests to the fasten API & lighthouse, all other requests should be sent as-is
|
||||
let reqUrl = new URL(req.url)
|
||||
let lighthouseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.lighthouse_api_endpoint_base))
|
||||
let apiUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base))
|
||||
|
||||
//skip database, header is sent automatically via PouchDB
|
||||
// let databaseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base))
|
||||
|
||||
if(
|
||||
(reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname))
|
||||
!((reqUrl.origin == apiUrl.origin && reqUrl.pathname.startsWith(apiUrl.pathname)) ||
|
||||
(reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname)))
|
||||
){
|
||||
//all requests to the lighthouse require the JWT
|
||||
console.log("making authorized request...")
|
||||
// Clone the request to add the new auth header.
|
||||
return next.handle(req)
|
||||
}
|
||||
|
||||
// Clone the request to add the new header.
|
||||
const authReq = req.clone({headers: req.headers.set('Authorization', 'Bearer ' + this.authService.GetAuthToken())});
|
||||
// catch the error, make specific functions for catching specific errors and you can chain through them with more catch operators
|
||||
return next.handle(authReq).pipe(catchError(x=> this.handleAuthError(x))); //here use an arrow function, otherwise you may get "Cannot read property 'navigate' of undefined" on angular 4.4.2/net core 2/webpack 2.70
|
||||
}
|
||||
// else if(){
|
||||
// //TODO: only CORS requests to the API endpoint require JWT, but they also require a custom header.
|
||||
//
|
||||
// //(reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname)) ||
|
||||
// // () ||
|
||||
// // (reqUrl.origin == apiUrl.origin && reqUrl.pathname.startsWith(apiUrl.pathname))
|
||||
//
|
||||
// }
|
||||
|
||||
return next.handle(req)
|
||||
|
||||
// let authToken = this.authService.GetAuthToken()
|
||||
// if(!authToken){
|
||||
// //no authToken available, lets just handle the request as-is
|
||||
// return next.handle(req)
|
||||
// }
|
||||
//
|
||||
// //only intercept requests to the Fasten API, Database & Lighthouse, all other requests should be sent as-is
|
||||
// let reqUrl = new URL(req.url)
|
||||
// let lighthouseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.lighthouse_api_endpoint_base))
|
||||
// let apiUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base))
|
||||
//
|
||||
// //skip database, header is sent automatically via PouchDB
|
||||
// // let databaseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base))
|
||||
//
|
||||
// if(
|
||||
// (reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname))
|
||||
// ){
|
||||
// //all requests to the lighthouse require the JWT
|
||||
// console.log("making authorized request...")
|
||||
// // Clone the request to add the new auth header.
|
||||
// const authReq = req.clone({headers: req.headers.set('Authorization', 'Bearer ' + this.authService.GetAuthToken())});
|
||||
// // catch the error, make specific functions for catching specific errors and you can chain through them with more catch operators
|
||||
// return next.handle(authReq).pipe(catchError(x=> this.handleAuthError(x))); //here use an arrow function, otherwise you may get "Cannot read property 'navigate' of undefined" on angular 4.4.2/net core 2/webpack 2.70
|
||||
// }
|
||||
// // else if(){
|
||||
// // //TODO: only CORS requests to the API endpoint require JWT, but they also require a custom header.
|
||||
// //
|
||||
// // //(reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname)) ||
|
||||
// // // () ||
|
||||
// // // (reqUrl.origin == apiUrl.origin && reqUrl.pathname.startsWith(apiUrl.pathname))
|
||||
// //
|
||||
// // }
|
||||
//
|
||||
// return next.handle(req)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||
import {FastenDbService} from './fasten-db.service';
|
||||
import {User} from '../../lib/models/fasten/user';
|
||||
import {User} from '../models/fasten/user';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
||||
import {ResponseWrapper} from '../models/response-wrapper';
|
||||
import * as Oauth from '@panva/oauth4webapi';
|
||||
import {SourceState} from '../models/fasten/source-state';
|
||||
import {Session} from '../models/database/session';
|
||||
import * as jose from 'jose';
|
||||
import {UserRegisteredClaims} from '../models/fasten/user-registered-claims';
|
||||
|
||||
|
@ -137,25 +135,29 @@ export class AuthService {
|
|||
if(!hasAuthToken){
|
||||
return false
|
||||
}
|
||||
//check if the authToken works
|
||||
let databaseEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base)
|
||||
try {
|
||||
let resp = await this._httpClient.get<any>(`${databaseEndpointBase}/_session`, {
|
||||
headers: new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`
|
||||
})
|
||||
}).toPromise()
|
||||
// logic to check if user is logged in here.
|
||||
let session = resp as Session
|
||||
if(!session.ok || session?.info?.authenticated != "jwt" || !session.userCtx?.name){
|
||||
//invalid session, not jwt auth, or username is empty
|
||||
return false
|
||||
}
|
||||
|
||||
//todo: check if the authToken has expired
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
|
||||
// //check if the authToken has expired.
|
||||
// let databaseEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base)
|
||||
// try {
|
||||
// let resp = await this._httpClient.get<any>(`${databaseEndpointBase}/_session`, {
|
||||
// headers: new HttpHeaders({
|
||||
// 'Content-Type': 'application/json',
|
||||
// Authorization: `Bearer ${authToken}`
|
||||
// })
|
||||
// }).toPromise()
|
||||
// // logic to check if user is logged in here.
|
||||
// let session = resp as Session
|
||||
// if(!session.ok || session?.info?.authenticated != "jwt" || !session.userCtx?.name){
|
||||
// //invalid session, not jwt auth, or username is empty
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// } catch (e) {
|
||||
// return false
|
||||
// }
|
||||
}
|
||||
|
||||
public GetAuthToken(): string {
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FastenDbService } from './fasten-db.service';
|
||||
import { FastenApiService } from './fasten-api.service';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('FastenDbService', () => {
|
||||
let service: FastenDbService;
|
||||
describe('FastenApiService', () => {
|
||||
let service: FastenApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
});
|
||||
service = TestBed.inject(FastenDbService);
|
||||
service = TestBed.inject(FastenApiService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
|
@ -0,0 +1,131 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {ResponseWrapper} from '../models/response-wrapper';
|
||||
import {Source} from '../models/fasten/source';
|
||||
import {User} from '../models/fasten/user';
|
||||
import {ResourceFhir} from '../models/fasten/resource_fhir';
|
||||
import {SourceSummary} from '../models/fasten/source-summary';
|
||||
import {Summary} from '../models/fasten/summary';
|
||||
import {MetadataSource} from '../models/fasten/metadata-source';
|
||||
import {AuthService} from './auth.service';
|
||||
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
||||
import {environment} from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FastenApiService {
|
||||
|
||||
constructor(private _httpClient: HttpClient, private router: Router, private authService: AuthService) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
SECURE ENDPOINTS
|
||||
*/
|
||||
getSummary(): Observable<Summary> {
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/summary`, )
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("Summary RESPONSE", response)
|
||||
return response.data as Summary
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createSource(source: Source): Observable<Source> {
|
||||
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source`, source)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("SOURCE RESPONSE", response)
|
||||
return response.data as Source
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createManualSource(file: File): Observable<Source> {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/manual`, formData)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("MANUAL SOURCE RESPONSE", response)
|
||||
return response.data as Source
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSources(): Observable<Source[]> {
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source`)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("SOURCE RESPONSE", response)
|
||||
return response.data as Source[]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSource(sourceId: string): Observable<Source> {
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}`)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("SOURCE RESPONSE", response)
|
||||
return response.data as Source
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSourceSummary(sourceId: string): Observable<SourceSummary> {
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}/summary`)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("SOURCE RESPONSE", response)
|
||||
return response.data as SourceSummary
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
syncSource(sourceId: string): Observable<any> {
|
||||
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}/sync`, {})
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("SOURCE RESPONSE", response)
|
||||
return response.data
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getResources(sourceResourceType?: string, sourceID?: string): Observable<ResourceFhir[]> {
|
||||
let queryParams = {}
|
||||
if(sourceResourceType){
|
||||
queryParams["sourceResourceType"] = sourceResourceType
|
||||
}
|
||||
if(sourceID){
|
||||
queryParams["sourceID"] = sourceID
|
||||
}
|
||||
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir`, {params: queryParams})
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("RESPONSE", response)
|
||||
return response.data as ResourceFhir[]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getResourceBySourceId(sourceId: string, resourceId: string): Observable<ResourceFhir> {
|
||||
|
||||
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir/${sourceId}/${resourceId}`)
|
||||
.pipe(
|
||||
map((response: ResponseWrapper) => {
|
||||
console.log("RESPONSE", response)
|
||||
return response.data as ResourceFhir
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
||||
import {User} from '../../lib/models/fasten/user';
|
||||
import {ResponseWrapper} from '../models/response-wrapper';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Summary} from '../../lib/models/fasten/summary';
|
||||
import {DocType} from '../../lib/database/constants';
|
||||
import {ResourceTypeCounts} from '../../lib/models/fasten/source-summary';
|
||||
import {Base64} from '../../lib/utils/base64';
|
||||
import * as jose from 'jose'
|
||||
|
||||
// PouchDB & plugins (must be similar to the plugins specified in pouchdb repository)
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import find from 'pouchdb-find';
|
||||
PouchDB.plugin(find); //does not work in
|
||||
import * as PouchUpsert from 'pouchdb-upsert';
|
||||
PouchDB.plugin(PouchUpsert);
|
||||
import * as PouchCrypto from 'crypto-pouch';
|
||||
PouchDB.plugin(PouchCrypto);
|
||||
import PouchAuth from 'pouchdb-authentication'
|
||||
import {PouchdbCrypto} from '../../lib/database/plugins/crypto';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
||||
import {AuthService} from './auth.service';
|
||||
PouchDB.plugin(PouchAuth);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FastenDbService extends PouchdbRepository {
|
||||
|
||||
|
||||
// There are 3 different ways to initialize the Database
|
||||
// - explicitly after signin/signup
|
||||
// - explicitly during web-worker init (not supported by this class, see PouchdbRepository.NewPouchdbRepositoryWebWorker)
|
||||
// - implicitly after Lighthouse redirect (when user is directed back to the app)
|
||||
// Three peices of information are required during intialization
|
||||
// - couchdb endpoint (constant, see environment.couchdb_endpoint_base)
|
||||
// - username
|
||||
// - JWT token
|
||||
constructor(private _httpClient: HttpClient, private authService: AuthService) {
|
||||
super(environment.couchdb_endpoint_base);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Try to get PouchDB database using token auth information
|
||||
* This method must handle 2 types of authentication
|
||||
* - pouchdb init after signin/signup
|
||||
* - implicit init after lighthouse redirect
|
||||
* @constructor
|
||||
*/
|
||||
public override async GetSessionDB(): Promise<PouchDB.Database> {
|
||||
if(this.pouchDb){
|
||||
console.log("Session DB already exists..")
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
//check if we have a JWT token (we should, otherwise the auth-guard would have redirected to login page)
|
||||
let authToken = this.authService.GetAuthToken()
|
||||
if(!authToken){
|
||||
throw new Error("no auth token found")
|
||||
}
|
||||
|
||||
//parse the authToken to get user information
|
||||
this.current_user = this.authService.GetCurrentUser().sub
|
||||
|
||||
// add JWT bearer token header to all requests
|
||||
// https://stackoverflow.com/questions/62129654/how-to-handle-jwt-authentication-with-rxdb
|
||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(this.current_user), {
|
||||
fetch: function (url, opts) {
|
||||
opts.headers.set('Authorization', `Bearer ${authToken}`)
|
||||
return PouchDB.fetch(url, opts);
|
||||
}
|
||||
})
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the crypto configuration for the authenticated user already available in the browser? Or do we need to import/generate new config.
|
||||
*/
|
||||
public async isCryptConfigAvailable(): Promise<boolean>{
|
||||
try {
|
||||
await this.GetSessionDB()
|
||||
let cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
||||
|
||||
return !!cryptConfig
|
||||
}catch(e){
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Close(): Promise<void> {
|
||||
return super.Close()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Summary
|
||||
|
||||
public async GetSummary(): Promise<Summary> {
|
||||
const summary = new Summary()
|
||||
summary.sources = await this.GetSources()
|
||||
.then((paginatedResp) => paginatedResp.rows)
|
||||
|
||||
// summary.patients = []
|
||||
summary.patients = await this.GetDB()
|
||||
.then((db) => {
|
||||
|
||||
return db.find({
|
||||
selector: {
|
||||
doc_type: DocType.ResourceFhir,
|
||||
source_resource_type: "Patient",
|
||||
}
|
||||
}).then((results) => {
|
||||
return Promise.all((results.docs || []).map((doc) => PouchdbCrypto.decryptDocument(db, doc)))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
summary.resource_type_counts = await this.findDocumentByPrefix(`${DocType.ResourceFhir}`, false)
|
||||
.then((paginatedResp) => {
|
||||
const lookup: {[name: string]: ResourceTypeCounts} = {}
|
||||
paginatedResp?.rows.forEach((resourceWrapper) => {
|
||||
const resourceIdParts = resourceWrapper.id.split(':')
|
||||
const resourceType = resourceIdParts[2]
|
||||
|
||||
let currentResourceStats = lookup[resourceType] || {
|
||||
count: 0,
|
||||
source_id: Base64.Decode(resourceIdParts[1]),
|
||||
resource_type: resourceType
|
||||
}
|
||||
currentResourceStats.count += 1
|
||||
lookup[resourceType] = currentResourceStats
|
||||
})
|
||||
|
||||
const arr = []
|
||||
for(let key in lookup){
|
||||
arr.push(lookup[key])
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
|
||||
return summary
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import {Observable} from 'rxjs';
|
|||
import {environment} from '../../environments/environment';
|
||||
import {map, tap} from 'rxjs/operators';
|
||||
import {ResponseWrapper} from '../models/response-wrapper';
|
||||
import {LighthouseSourceMetadata} from '../../lib/models/lighthouse/lighthouse-source-metadata';
|
||||
import {LighthouseSourceMetadata} from '../models/lighthouse/lighthouse-source-metadata';
|
||||
import * as Oauth from '@panva/oauth4webapi';
|
||||
import {SourceState} from '../models/fasten/source-state';
|
||||
import {MetadataSource} from '../models/fasten/metadata-source';
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('QueueService', () => {
|
||||
let service: QueueService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
});
|
||||
service = TestBed.inject(QueueService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -1,45 +0,0 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {fromWorker} from 'observable-webworker';
|
||||
import {Source} from '../../lib/models/database/source';
|
||||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
||||
import {ToastService} from '../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../models/fasten/toast';
|
||||
import {FastenDbService} from '../services/fasten-db.service';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {AuthService} from '../services/auth.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class QueueService {
|
||||
|
||||
constructor(private toastService: ToastService, private authService: AuthService) { }
|
||||
|
||||
runSourceSyncWorker(source: Source):Observable<string> {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
const sourceSync = new SourceSyncMessage()
|
||||
sourceSync.source = source
|
||||
sourceSync.current_user = this.authService.GetCurrentUser().sub
|
||||
sourceSync.auth_token = this.authService.GetAuthToken()
|
||||
sourceSync.couchdb_endpoint_base = environment.couchdb_endpoint_base
|
||||
sourceSync.fasten_api_endpoint_base = environment.fasten_api_endpoint_base
|
||||
const input$: Observable<string> = of(JSON.stringify(sourceSync));
|
||||
return fromWorker<string, string>(() => new Worker(new URL('./source-sync.worker', import.meta.url), {type: 'module'}), input$)
|
||||
// .subscribe(message => {
|
||||
// console.log(`Got message`, message);
|
||||
// });
|
||||
}else {
|
||||
// Web workers are not supported in this environment.
|
||||
// You should add a fallback so that your program still executes correctly.
|
||||
console.error("WORKERS ARE NOT SUPPORTED")
|
||||
|
||||
const toastNotificaiton = new ToastNotification()
|
||||
toastNotificaiton.type = ToastType.Error
|
||||
toastNotificaiton.message = "Your browser does not support web-workers. Cannot continue."
|
||||
toastNotificaiton.autohide = false
|
||||
this.toastService.show(toastNotificaiton)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
/// <reference lib="webworker" />
|
||||
|
||||
import {DoWork, runWorker} from 'observable-webworker';
|
||||
import {Observable} from 'rxjs';
|
||||
import {mergeMap} from 'rxjs/operators';
|
||||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
||||
import {NewPouchdbRepositoryWebWorker, PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
||||
import {NewClient} from '../../lib/conduit/factory';
|
||||
import {Source} from '../../lib/models/database/source';
|
||||
import {ClientConfig} from '../../lib/models/client/client-config';
|
||||
import {client} from 'fhirclient';
|
||||
|
||||
export class SourceSyncWorker implements DoWork<string, string> {
|
||||
public work(input$: Observable<string>): Observable<string> {
|
||||
return input$.pipe(
|
||||
//mergeMap allows us to convert a promise into an observable
|
||||
// https://stackoverflow.com/questions/53649294/how-to-handle-for-promise-inside-a-piped-map
|
||||
mergeMap(msg => {
|
||||
try {
|
||||
console.log(msg); // outputs 'Hello from main thread'
|
||||
const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
|
||||
|
||||
const db = NewPouchdbRepositoryWebWorker({current_user: sourceSyncMessage.current_user, auth_token: sourceSyncMessage.auth_token}, sourceSyncMessage.couchdb_endpoint_base)
|
||||
|
||||
let clientConfig = new ClientConfig()
|
||||
clientConfig.fasten_api_endpoint_base = sourceSyncMessage.fasten_api_endpoint_base
|
||||
const client = NewClient(sourceSyncMessage.source.source_type, new Source(sourceSyncMessage.source), clientConfig)
|
||||
//TODO: validate the FHIR version from the datasource matches the client
|
||||
// if the source token has been refreshed, we need to store it in the DB.
|
||||
// await db.UpsertSource()
|
||||
|
||||
|
||||
//lets refresh the source information if required.
|
||||
console.log("!!!!!!!!!!!!!!STARTING WORKER SYNC!!!!!!!!!", sourceSyncMessage)
|
||||
return client.RefreshSourceToken()
|
||||
.then((wasSourceRefreshed) => {
|
||||
if(wasSourceRefreshed){
|
||||
//the source was updated, we need to save the updated source information
|
||||
return db.UpsertSource(client.source)
|
||||
.then(() => {
|
||||
return client
|
||||
})
|
||||
}
|
||||
return client
|
||||
})
|
||||
.then((client) => {
|
||||
return client.SyncAll(db)
|
||||
})
|
||||
.then((resp) => {
|
||||
console.log("!!!!!!!!!!!!!COMPLETE WORKER SYNC!!!!!!!!!!", resp)
|
||||
sourceSyncMessage.response = resp
|
||||
return JSON.stringify(sourceSyncMessage)
|
||||
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("!!!!!!!!!!!!!ERROR WORKER SYNC!!!!!!!!!!", err)
|
||||
throw err
|
||||
})
|
||||
// return from(resp)
|
||||
|
||||
} catch (e) {
|
||||
console.log("CAUGHT ERROR", e)
|
||||
console.trace(e)
|
||||
throw e
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
runWorker(SourceSyncWorker);
|
|
@ -1,84 +0,0 @@
|
|||
import {SourceType} from '../models/database/source_types';
|
||||
import {Source} from '../models/database/source';
|
||||
import {IClient} from './interface';
|
||||
|
||||
import {HealthITClient} from './fhir/sandbox/healthit_client';
|
||||
import {LogicaClient} from './fhir/sandbox/logica_client';
|
||||
import {AthenaClient} from './fhir/sandbox/athena_client';
|
||||
|
||||
import {CareEvolutionClient} from './fhir/platforms/careevolution_client';
|
||||
import {CernerClient} from './fhir/platforms/cerner_client';
|
||||
import {EpicClient} from './fhir/platforms/epic_client';
|
||||
|
||||
import {AetnaClient} from './fhir/aetna_client';
|
||||
import {BlueButtonClient} from './fhir/bluebutton_client';
|
||||
import {CignaClient} from './fhir/cigna_client';
|
||||
import {ClientConfig} from '../models/client/client-config';
|
||||
|
||||
export function NewClient(sourceType: SourceType, source: Source, clientConfig: ClientConfig): IClient {
|
||||
|
||||
switch(sourceType) {
|
||||
|
||||
//sandbox
|
||||
case SourceType.Athena:
|
||||
return new AthenaClient(source, clientConfig)
|
||||
case SourceType.HealthIT:
|
||||
return new HealthITClient(source, clientConfig)
|
||||
case SourceType.Logica:
|
||||
return new LogicaClient(source, clientConfig )
|
||||
|
||||
//platforms
|
||||
case SourceType.CareEvolution:
|
||||
return new CareEvolutionClient(source, clientConfig)
|
||||
case SourceType.Cerner:
|
||||
return new CernerClient(source, clientConfig)
|
||||
case SourceType.Epic:
|
||||
return new EpicClient(source, clientConfig)
|
||||
|
||||
//providers
|
||||
case SourceType.Aetna:
|
||||
return new AetnaClient(source, clientConfig)
|
||||
|
||||
case SourceType.Amerigroup:
|
||||
case SourceType.AmerigroupMedicaid:
|
||||
case SourceType.Anthem:
|
||||
case SourceType.AnthemBluecrossCA:
|
||||
case SourceType.BluecrossBlueshieldKansasMedicare:
|
||||
case SourceType.BluecrossBlueshieldKansas:
|
||||
case SourceType.BluecrossBlueshieldNY:
|
||||
case SourceType.BlueMedicareAdvantage:
|
||||
case SourceType.ClearHealthAlliance:
|
||||
case SourceType.DellChildrens:
|
||||
case SourceType.EmpireBlue:
|
||||
case SourceType.EmpireBlueMedicaid:
|
||||
case SourceType.HealthyBlueLA:
|
||||
case SourceType.HealthyBlueLAMedicaid:
|
||||
case SourceType.HealthyBlueMO:
|
||||
case SourceType.HealthyBlueMOMedicaid:
|
||||
case SourceType.HealthyBlueNC:
|
||||
case SourceType.HealthyBlueNCMedicaid:
|
||||
case SourceType.HealthyBlueNE:
|
||||
case SourceType.HealthyBlueSC:
|
||||
case SourceType.HighmarkWesternNY:
|
||||
case SourceType.SimplyHealthcareMedicaid:
|
||||
case SourceType.SimplyHealthcareMedicare:
|
||||
case SourceType.SummitCommunityCare:
|
||||
case SourceType.Unicare:
|
||||
case SourceType.UnicareMA:
|
||||
case SourceType.UnicareMedicaid:
|
||||
return new CareEvolutionClient(source, clientConfig)
|
||||
|
||||
case SourceType.UCSF:
|
||||
return new EpicClient(source, clientConfig)
|
||||
|
||||
case SourceType.Cigna:
|
||||
return new CignaClient(source, clientConfig)
|
||||
case SourceType.BlueButton:
|
||||
return new BlueButtonClient(source, clientConfig)
|
||||
|
||||
// case SourceType.Manual:
|
||||
// return new ManualClient(source)
|
||||
default:
|
||||
throw new Error(`Unknown Source Type: ${sourceType}`)
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import {IClient} from '../interface';
|
||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
||||
import {Source} from '../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../database/interface';
|
||||
import {UpsertSummary} from '../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../models/client/client-config';
|
||||
|
||||
export class AetnaClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aetna overrides the SyncAll function because Patient-everything operation uses a non-standard endpoint
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const bundle = await this.GetResourceBundlePaginated("Patient")
|
||||
|
||||
const wrappedResourceModels = await this.ProcessBundle(bundle)
|
||||
//todo, create the resources in dependency order
|
||||
return await db.UpsertResources(wrappedResourceModels)
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
import {BaseClient} from './base_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
|
||||
// @ts-ignore
|
||||
import * as BaseClient_GetRequest from './fixtures/BaseClient_GetRequest.json';
|
||||
// @ts-ignore
|
||||
import * as BaseClient_GetFhirVersion from './fixtures/BaseClient_GetFhirVersion.json';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
|
||||
class TestClient extends BaseClient {
|
||||
constructor(source: Source) {
|
||||
super(source, new ClientConfig());
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseClient', () => {
|
||||
let client: TestClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new TestClient(new Source({
|
||||
"authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
|
||||
"token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
|
||||
"introspection_endpoint": "",
|
||||
"issuer": "https://fhirsandbox.healthit.go",
|
||||
"api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
|
||||
"client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
|
||||
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
|
||||
"confidential": false,
|
||||
"source_type": "healthit",
|
||||
"patient": "placeholder",
|
||||
"access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
|
||||
"refresh_token": "",
|
||||
"expires_at": 16649837100, //aug 11, 2497 (for testing)
|
||||
}));
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(client).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('GetRequest', () => {
|
||||
it('should make an authorized request', async () => {
|
||||
|
||||
//setup
|
||||
let response = new Response(JSON.stringify(BaseClient_GetRequest));
|
||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}`});
|
||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
||||
|
||||
//test
|
||||
const resp = await client.GetRequest(`Patient/${client.source.patient}`)
|
||||
|
||||
//expect
|
||||
expect(resp.resourceType).toEqual("Patient");
|
||||
expect(resp.id).toEqual("123d41e1-0f71-4e9f-8eb2-d1b1330201a6");
|
||||
});
|
||||
})
|
||||
|
||||
describe('GetFhirVersion', () => {
|
||||
it('should make an authorized request', async () => {
|
||||
//setup
|
||||
let response = new Response(JSON.stringify(BaseClient_GetFhirVersion));
|
||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/metadata`});
|
||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
||||
|
||||
//test
|
||||
const resp = await client.GetFhirVersion()
|
||||
|
||||
//expect
|
||||
expect(resp).toEqual("4.0.1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
})
|
|
@ -1,153 +0,0 @@
|
|||
import {Source} from '../../../models/database/source';
|
||||
import * as Oauth from '@panva/oauth4webapi';
|
||||
import {IResourceRaw} from '../../interface';
|
||||
import {GetEndpointAbsolutePath} from '../../../utils/endpoint_absolute_path';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
|
||||
class SourceUpdateStatus {
|
||||
is_updated: boolean = false
|
||||
source: Source
|
||||
}
|
||||
|
||||
// BaseClient is an abstract/partial class, its intended to be used by FHIR clients, and generically handle OAuth requests.
|
||||
export abstract class BaseClient {
|
||||
|
||||
private clientConfig: ClientConfig
|
||||
private oauthClient: Oauth.Client
|
||||
private oauthAuthorizationServer: Oauth.AuthorizationServer
|
||||
public source: Source
|
||||
public headers: Headers
|
||||
public fhirVersion: string
|
||||
|
||||
|
||||
protected constructor(source: Source, clientConfig: ClientConfig) {
|
||||
this.source = source
|
||||
this.clientConfig = clientConfig
|
||||
this.headers = new Headers()
|
||||
|
||||
//init Oauth client based on source configuration
|
||||
this.oauthClient = {
|
||||
client_id: source.client_id,
|
||||
client_secret: "placeholder" //this is always a placeholder, if client_secret is required (for confidential clients), token_endpoint will be Lighthouse server.
|
||||
}
|
||||
this.oauthAuthorizationServer = {
|
||||
issuer: source.issuer,
|
||||
authorization_endpoint: source.authorization_endpoint,
|
||||
token_endpoint: source.token_endpoint,
|
||||
introspection_endpoint: source.introspection_endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function gets the FhirVersion as specified by the api CapabilityStatement endpoint (metadata)
|
||||
* https://build.fhir.org/capabilitystatement.html
|
||||
* @constructor
|
||||
*/
|
||||
public async GetFhirVersion(): Promise<any> {
|
||||
return this.GetRequest("metadata")
|
||||
.then((resp) => {
|
||||
return resp.fhirVersion
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will make an authenticated request against an OAuth protected resource. If the AccessToken used has expired, it will attempt
|
||||
* to use a refresh token (if present) to get a new AccessToken.
|
||||
* @param resourceSubpathOrNext
|
||||
* @constructor
|
||||
*/
|
||||
public async GetRequest(resourceSubpathOrNext: string): Promise<any> {
|
||||
|
||||
//check if the url is absolute
|
||||
let resourceUrl: string
|
||||
if (!(resourceSubpathOrNext.indexOf('http://') === 0 || resourceSubpathOrNext.indexOf('https://') === 0)) {
|
||||
//not absolute, so lets prefix with the source api endpoint
|
||||
resourceUrl = `${this.source.api_endpoint_base_url.trimEnd()}/${resourceSubpathOrNext.trimStart()}`
|
||||
} else {
|
||||
resourceUrl = resourceSubpathOrNext
|
||||
}
|
||||
if(this.source.cors_relay_required){
|
||||
//this endpoint requires a CORS relay
|
||||
//get the path to the Fasten server, and append `cors/` and then append the request url
|
||||
let resourceParts = new URL(resourceUrl)
|
||||
resourceUrl = GetEndpointAbsolutePath(globalThis.location, this.clientConfig.fasten_api_endpoint_base) + `/cors/${resourceParts.hostname}${resourceParts.pathname}${resourceParts.search}`
|
||||
}
|
||||
|
||||
|
||||
//refresh the source if required
|
||||
await this.RefreshSourceToken()
|
||||
|
||||
//make a request to the protected resource
|
||||
const resp = await Oauth.protectedResourceRequest(this.source.access_token, 'GET', new URL(resourceUrl), this.headers, null)
|
||||
|
||||
if(resp.status >=300 || resp.status < 200){
|
||||
// b, _ := io.ReadAll(resp.Body)
|
||||
throw new Error(`An error occurred during request ${resourceUrl} - ${resp.status} - ${resp.statusText} ${await resp.text()}`)
|
||||
}
|
||||
return resp.json()
|
||||
// err = ParseBundle(resp.Body, decodeModelPtr)
|
||||
// return err
|
||||
}
|
||||
|
||||
public async RefreshSourceToken(): Promise<boolean>{
|
||||
|
||||
let sourceUpdateStatus = await this.refreshExpiredTokenIfRequired(this.source)
|
||||
this.source = sourceUpdateStatus.source
|
||||
return sourceUpdateStatus.is_updated
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Protected methods
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Private methods
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private async refreshExpiredTokenIfRequired(source: Source): Promise<SourceUpdateStatus> {
|
||||
const sourceUpdateStatus = new SourceUpdateStatus()
|
||||
//check if token has expired, and a refreshtoken is available
|
||||
// Note: source.expires_at is in seconds, Date.now() is in milliseconds.
|
||||
if(source.expires_at > Math.floor(Date.now() / 1000)) { //not expired return
|
||||
sourceUpdateStatus.source = source
|
||||
return Promise.resolve(sourceUpdateStatus)
|
||||
}
|
||||
if(!source.refresh_token){
|
||||
return Promise.reject(new Error("access token is expired, but no refresh token available"))
|
||||
}
|
||||
|
||||
console.log("access token expired, refreshing...")
|
||||
return Oauth.refreshTokenGrantRequest(this.oauthAuthorizationServer, this.oauthClient, source.refresh_token)
|
||||
.then((refreshTokenResp) => {
|
||||
return Oauth.processRefreshTokenResponse(this.oauthAuthorizationServer, this.oauthClient, refreshTokenResp)
|
||||
})
|
||||
.then((newToken) => {
|
||||
|
||||
if(newToken.access_token != source.access_token){
|
||||
sourceUpdateStatus.is_updated = true
|
||||
// {
|
||||
// access_token: 'token',
|
||||
// token_type: 'bearer',
|
||||
// expires_in: 60,
|
||||
// scope: 'api:read',
|
||||
// refresh_token: 'refresh_token',
|
||||
// }
|
||||
|
||||
source.access_token = newToken.access_token as string
|
||||
// @ts-ignore
|
||||
source.expires_at = Math.floor(Date.now() / 1000) + parseInt(newToken.expires_in);
|
||||
|
||||
// update the "source" credential with new data (which will need to be sent
|
||||
// Don't overwrite `RefreshToken` with an empty value
|
||||
if(newToken.refresh_token != ""){
|
||||
source.refresh_token = newToken.refresh_token as string
|
||||
}
|
||||
}
|
||||
sourceUpdateStatus.source = source
|
||||
return sourceUpdateStatus
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
import {FHIR401Client} from './fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IResourceBundleRaw} from '../../interface';
|
||||
import {ResourceFhir} from '../../../models/database/resource_fhir';
|
||||
import {NewPouchdbRepositoryWebWorker} from '../../../database/pouchdb_repository';
|
||||
import {Base64} from '../../../utils/base64';
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// @ts-ignore
|
||||
import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json';
|
||||
// @ts-ignore
|
||||
import * as FHIR401Client_ExtractResourceReference from './fixtures/FHIR401Client_ExtractResourceReference.json';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {PouchdbCrypto} from '../../../database/plugins/crypto';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
|
||||
class TestClient extends FHIR401Client {
|
||||
constructor(source: Source) {
|
||||
super(source, new ClientConfig());
|
||||
}
|
||||
|
||||
public async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
|
||||
return super.ProcessBundle(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
describe('FHIR401Client', () => {
|
||||
let client: TestClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new TestClient(new Source({
|
||||
"_id": "source:aetna:12345",
|
||||
"authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
|
||||
"token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
|
||||
"introspection_endpoint": "",
|
||||
"issuer": "https://fhirsandbox.healthit.go",
|
||||
"api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
|
||||
"client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
|
||||
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
|
||||
"confidential": false,
|
||||
"source_type": "healthit",
|
||||
"patient": "placeholder",
|
||||
"access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
|
||||
"refresh_token": "",
|
||||
"expires_at": 16649837100, //aug 11, 2497 (for testing)
|
||||
}));
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(client).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('ProcessBundle', () => {
|
||||
it('should correctly wrap each BundleEntry with ResourceFhir', async () => {
|
||||
|
||||
//setup
|
||||
|
||||
//test
|
||||
const resp = await client.ProcessBundle(FHIR401Client_ProcessBundle)
|
||||
//expect
|
||||
expect(resp.length).toEqual(206);
|
||||
expect(resp[0].source_resource_id).toEqual("c088b7af-fc41-43cc-ab80-4a9ab8d47cd9");
|
||||
expect(resp[0].source_resource_type).toEqual("Patient");
|
||||
});
|
||||
})
|
||||
|
||||
describe('SyncAll', () => {
|
||||
let repository: IDatabaseRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
let current_user = uuidv4() as string
|
||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
repository = NewPouchdbRepositoryWebWorker({current_user: current_user, auth_token:""}, '/database', new PouchDB("FHIR401Client-"+ current_user));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if(repository){
|
||||
const db = await repository.GetDB()
|
||||
db.destroy() //wipe the db.
|
||||
}
|
||||
})
|
||||
it('should correctly add resources to the database', async () => {
|
||||
//setup
|
||||
let response = new Response(JSON.stringify(FHIR401Client_ProcessBundle));
|
||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}/$everything`});
|
||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
||||
|
||||
//test
|
||||
const resp = await client.SyncAll(repository)
|
||||
const firstResourceFhir = resp.updatedResources[0]
|
||||
const resourceIdParts = firstResourceFhir.split(":")
|
||||
|
||||
//expect
|
||||
expect(resp.totalResources).toEqual(206);
|
||||
expect(resp.updatedResources.length).toEqual(206);
|
||||
expect(firstResourceFhir).toEqual('resource_fhir:b64.c291cmNlOmFldG5hOjEyMzQ1:Patient:c088b7af-fc41-43cc-ab80-4a9ab8d47cd9');
|
||||
expect(Base64.Decode(resourceIdParts[1])).toEqual("source:aetna:12345");
|
||||
|
||||
|
||||
}, 10000);
|
||||
})
|
||||
|
||||
describe('ExtractResourceReference', () => {
|
||||
it('should correctly extract resource identifier from raw resources', async () => {
|
||||
|
||||
//setup
|
||||
|
||||
//test
|
||||
const resp = await client.ExtractResourceReference("CarePlan", FHIR401Client_ExtractResourceReference)
|
||||
//expect
|
||||
expect(resp.length).toEqual(2);
|
||||
expect(resp).toEqual(['Encounter/97961321', 'Practitioner/12763770']);
|
||||
});
|
||||
})
|
||||
|
||||
})
|
|
@ -1,594 +0,0 @@
|
|||
import {IClient, IResourceBundleEntryRaw, IResourceBundleRaw, IResourceRaw} from '../../interface';
|
||||
import {BaseClient} from './base_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {ResourceFhir} from '../../../models/database/resource_fhir';
|
||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class FHIR401Client extends BaseClient implements IClient {
|
||||
|
||||
//clients extending this class must validate fhirVersion matches using conformance/metadata url.
|
||||
fhirVersion = "4.0.1"
|
||||
|
||||
// https://build.fhir.org/ig/HL7/US-Core/
|
||||
usCoreResources: string[] = [
|
||||
"AllergyIntolerance",
|
||||
//"Binary",
|
||||
"CarePlan",
|
||||
"CareTeam",
|
||||
"Condition",
|
||||
//"Coverage",
|
||||
"Device",
|
||||
"DiagnosticReport",
|
||||
"DocumentReference",
|
||||
"Encounter",
|
||||
"Goal",
|
||||
"Immunization",
|
||||
//"Location",
|
||||
//"Medication",
|
||||
//"MedicationRequest",
|
||||
"Observation",
|
||||
//"Organization",
|
||||
//"Patient",
|
||||
//"Practitioner",
|
||||
//"PractitionerRole",
|
||||
"Procedure",
|
||||
//"Provenance",
|
||||
//"RelatedPerson",
|
||||
// "ServiceRequest",
|
||||
// "Specimen",
|
||||
]
|
||||
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function attempts to retrieve a Patient Bundle and sync all resources to the database
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
public async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const bundle = await this.GetPatientBundle(this.source.patient)
|
||||
|
||||
const wrappedResourceModels = await this.ProcessBundle(bundle)
|
||||
//todo, create the resources in dependency order
|
||||
|
||||
return db.UpsertResources(wrappedResourceModels)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* If Patient-everything api endpoint is unavailable (SyncAll) this function can be used to search for each resource associated with a Patient
|
||||
* and sync them to the database.
|
||||
* @param db
|
||||
* @param resourceNames
|
||||
* @constructor
|
||||
*/
|
||||
public async SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>{
|
||||
//Store the Patient
|
||||
const patientResource = await this.GetPatient(this.source.patient)
|
||||
|
||||
const patientResourceFhir = new ResourceFhir()
|
||||
patientResourceFhir.source_id = this.source._id
|
||||
patientResourceFhir.source_resource_type = patientResource.resourceType
|
||||
patientResourceFhir.source_resource_id = patientResource.id
|
||||
patientResourceFhir.resource_raw = patientResource
|
||||
|
||||
const upsertSummary = await db.UpsertResource(patientResourceFhir)
|
||||
|
||||
//error map storage.
|
||||
let syncErrors = {}
|
||||
|
||||
//Store all other resources.
|
||||
for(let resourceType of resourceNames) {
|
||||
|
||||
try {
|
||||
let bundle = await this.GetResourceBundlePaginated(`${resourceType}?patient=${this.source.patient}`)
|
||||
let wrappedResourceModels = await this.ProcessBundle(bundle)
|
||||
let resourceUpsertSummary = await db.UpsertResources(wrappedResourceModels)
|
||||
|
||||
upsertSummary.updatedResources = upsertSummary.updatedResources.concat(resourceUpsertSummary.updatedResources)
|
||||
upsertSummary.totalResources += resourceUpsertSummary.totalResources
|
||||
|
||||
|
||||
// check if theres any "extracted" resource references that we should sync as well
|
||||
let extractedResourceReferences = []
|
||||
extractedResourceReferences = wrappedResourceModels.reduce((previousVal, wrappedResource) => {
|
||||
return previousVal.concat(this.ExtractResourceReference(wrappedResource.source_resource_type, wrappedResource.resource_raw))
|
||||
}, extractedResourceReferences)
|
||||
|
||||
|
||||
if(extractedResourceReferences.length > 0 ){
|
||||
console.log("Extracted Resource References", extractedResourceReferences)
|
||||
|
||||
let extractedResourceBundle = await this.GenerateResourceBundleFromResourceIds(extractedResourceReferences)
|
||||
let wrappedExtractedResourceBundle = await this.ProcessBundle(extractedResourceBundle)
|
||||
let extractedResourceUpsertSummary = await db.UpsertResources(wrappedExtractedResourceBundle)
|
||||
|
||||
upsertSummary.updatedResources = upsertSummary.updatedResources.concat(extractedResourceUpsertSummary.updatedResources)
|
||||
upsertSummary.totalResources += extractedResourceUpsertSummary.totalResources
|
||||
}
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`An error occurred while processing ${resourceType} bundle ${this.source.patient}`)
|
||||
syncErrors[resourceType] = e
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: correctly return newly inserted documents
|
||||
return upsertSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a raw resource payload, this function will determine if there are references to other resources, and extract them if so
|
||||
* This is useful because search (by patient id) is disabled for certain resource types.
|
||||
* @param sourceResourceType
|
||||
* @param resourceRaw
|
||||
* @constructor
|
||||
*/
|
||||
public ExtractResourceReference(sourceResourceType: string, resourceRaw): string[] {
|
||||
let resourceRefs = []
|
||||
|
||||
switch (sourceResourceType) {
|
||||
case "CarePlan":
|
||||
// encounter can contain
|
||||
//- Encounter
|
||||
resourceRefs.push(resourceRaw.encounter?.reference)
|
||||
|
||||
//author can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- CareTeam
|
||||
//- RelatedPerson
|
||||
resourceRefs.push(resourceRaw.author?.reference)
|
||||
|
||||
|
||||
//contributor can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- CareTeam
|
||||
//- RelatedPerson
|
||||
resourceRefs.push(resourceRaw.contributor?.reference)
|
||||
|
||||
//careTeam can contain
|
||||
//- CareTeam
|
||||
resourceRefs.push(resourceRaw.careTeam?.reference)
|
||||
|
||||
break;
|
||||
case "CareTeam":
|
||||
// encounter can contain
|
||||
//- Encounter
|
||||
resourceRefs.push(resourceRaw.encounter?.reference)
|
||||
|
||||
//participant[x].member can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- CareTeam
|
||||
//- RelatedPerson
|
||||
//participant[x].onBehalfOf can contain
|
||||
//- Organization
|
||||
resourceRaw.participant?.map((participant) => {
|
||||
resourceRefs.push(participant.member?.reference)
|
||||
resourceRefs.push(participant.onBehalfOf?.reference)
|
||||
})
|
||||
|
||||
//managingOrganization
|
||||
//- Organization
|
||||
resourceRaw.managingOrganization?.map((managingOrganization) => {
|
||||
resourceRefs.push(managingOrganization.reference)
|
||||
})
|
||||
break;
|
||||
case "Condition":
|
||||
// recorder can contain
|
||||
//- Practitioner
|
||||
//- PractitionerRole
|
||||
//- Patient
|
||||
//- RelatedPerson
|
||||
resourceRefs.push(resourceRaw.recorder?.reference)
|
||||
|
||||
// asserter can contain
|
||||
//- Practitioner
|
||||
//- PractitionerRole
|
||||
//- Patient
|
||||
//- RelatedPerson
|
||||
resourceRefs.push(resourceRaw.asserter?.reference)
|
||||
|
||||
break;
|
||||
case "DiagnosticReport":
|
||||
//basedOn[x] can contain
|
||||
//- CarePlan
|
||||
//- ImmunizationRecommendation
|
||||
//- MedicationRequest
|
||||
//- NutritionOrder
|
||||
//- ServiceRequest
|
||||
resourceRaw.basedOn?.map((basedOn) => {
|
||||
resourceRefs.push(basedOn.reference)
|
||||
})
|
||||
|
||||
// performer[x] can contain
|
||||
//- Practitioner
|
||||
//- PractitionerRole
|
||||
//- Organization
|
||||
//- CareTeam
|
||||
resourceRaw.performer?.map((performer) => {
|
||||
resourceRefs.push(performer.reference)
|
||||
})
|
||||
break;
|
||||
case "DocumentReference":
|
||||
//author[x] can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- CareTeam
|
||||
//- Device
|
||||
resourceRaw.author?.map((author) => {
|
||||
resourceRefs.push(author.reference)
|
||||
})
|
||||
|
||||
//authenticator can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- PractitionerRole
|
||||
resourceRefs.push(resourceRaw.authenticator?.reference)
|
||||
|
||||
// custodian can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.custodian?.reference)
|
||||
|
||||
// relatesTo.target
|
||||
//- DocumentReference
|
||||
resourceRaw.relatesTo?.map((relatesTo) => {
|
||||
resourceRefs.push(relatesTo.target?.reference)
|
||||
})
|
||||
|
||||
//content.attachment can contain
|
||||
//- Attachment
|
||||
break
|
||||
case "Encounter":
|
||||
// basedOn[x] can contain
|
||||
//- ServiceRequest
|
||||
resourceRaw.basedOn?.map((basedOn) => {
|
||||
resourceRefs.push(basedOn.reference)
|
||||
})
|
||||
|
||||
//participant[x].individual can contain
|
||||
//- Practitioner
|
||||
//- PractitionerRole
|
||||
//- RelatedPerson
|
||||
resourceRaw.participant?.map((participant) => {
|
||||
resourceRefs.push(participant.individual?.reference)
|
||||
})
|
||||
|
||||
//reasonReference[x] can contain
|
||||
//- Condition
|
||||
//- Procedure
|
||||
//- Observation
|
||||
//- ImmunizationRecommendation
|
||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
||||
resourceRefs.push(reasonReference.reference)
|
||||
})
|
||||
|
||||
//hospitalization.origin can contain
|
||||
//- Location
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.hospitalization?.origin?.reference)
|
||||
|
||||
//hospitalization.destination can contain
|
||||
//- Location
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.hospitalization?.destination?.reference)
|
||||
|
||||
//location[x].location can contain
|
||||
//- Location
|
||||
resourceRaw.location?.map((location) => {
|
||||
resourceRefs.push(location.location?.reference)
|
||||
})
|
||||
|
||||
//serviceProvider can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.serviceProvider?.reference)
|
||||
|
||||
break
|
||||
case "Immunization":
|
||||
// location can contain
|
||||
//- Location
|
||||
resourceRefs.push(resourceRaw.location?.reference)
|
||||
|
||||
// manufacturer can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.manufacturer?.reference)
|
||||
|
||||
//performer[x].actor can contain
|
||||
//- Practitioner | PractitionerRole | Organization
|
||||
resourceRaw.performer?.map((performer) => {
|
||||
resourceRefs.push(performer.actor?.reference)
|
||||
})
|
||||
|
||||
//reasonReference[x] can contain
|
||||
//- Condition | Observation | DiagnosticReport
|
||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
||||
resourceRefs.push(reasonReference.reference)
|
||||
})
|
||||
|
||||
//protocolApplied[x].authority can contain
|
||||
//- Organization
|
||||
resourceRaw.protocolApplied?.map((protocolApplied) => {
|
||||
resourceRefs.push(protocolApplied.authority?.reference)
|
||||
})
|
||||
break
|
||||
case "Location":
|
||||
// managingOrganization can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.managingOrganization?.reference)
|
||||
|
||||
// partOf can contain
|
||||
//- Location
|
||||
resourceRefs.push(resourceRaw.partOf?.reference)
|
||||
|
||||
break
|
||||
case "MedicationRequest":
|
||||
// reported.reportedReference can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- RelatedPerson
|
||||
resourceRefs.push(resourceRaw.reported?.reportedReference?.reference)
|
||||
|
||||
// medication[x] can contain
|
||||
//- Medication
|
||||
resourceRefs.push(resourceRaw.reported?.reportedReference?.reference)
|
||||
|
||||
// requester can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- RelatedPerson
|
||||
//- Device
|
||||
resourceRefs.push(resourceRaw.requester?.reference)
|
||||
|
||||
// performer can contain
|
||||
//- Practitioner | PractitionerRole | Organization | Patient | Device | RelatedPerson | CareTeam
|
||||
resourceRefs.push(resourceRaw.performer?.reference)
|
||||
// recorder can contain
|
||||
//- Practitioner | PractitionerRole
|
||||
resourceRefs.push(resourceRaw.recorder?.reference)
|
||||
|
||||
//TODO: reasonReference
|
||||
//TODO: basedOn
|
||||
//TODO: insurance
|
||||
|
||||
// dispenseRequest.performer can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.dispenseRequest?.performer?.reference)
|
||||
break
|
||||
case "Observation":
|
||||
//basedOn[x] can contain
|
||||
//- CarePlan | DeviceRequest | ImmunizationRecommendation | MedicationRequest | NutritionOrder | ServiceRequest
|
||||
resourceRaw.basedOn?.map((basedOn) => {
|
||||
resourceRefs.push(basedOn.reference)
|
||||
})
|
||||
|
||||
// partOf[x] can contain
|
||||
//- MedicationAdministration | MedicationDispense | MedicationStatement | Procedure | Immunization | ImagingStudy
|
||||
resourceRaw.partOf?.map((partOf) => {
|
||||
resourceRefs.push(partOf.reference)
|
||||
})
|
||||
// performer[x] can contain
|
||||
//- Practitioner | PractitionerRole | Organization | CareTeam | Patient | RelatedPerson
|
||||
resourceRaw.performer?.map((performer) => {
|
||||
resourceRefs.push(performer.reference)
|
||||
})
|
||||
// device can contain
|
||||
//- Device | DeviceMetric
|
||||
resourceRefs.push(resourceRaw.device?.reference)
|
||||
|
||||
break
|
||||
case "PractitionerRole":
|
||||
// practitioner can contain
|
||||
//- Practitioner
|
||||
resourceRefs.push(resourceRaw.practitioner?.reference)
|
||||
|
||||
//organization can contain
|
||||
//- Organization
|
||||
resourceRefs.push(resourceRaw.organization?.reference)
|
||||
|
||||
//location can contain
|
||||
//- Location
|
||||
resourceRefs.push(resourceRaw.location?.reference)
|
||||
|
||||
//TODO: healthcareService
|
||||
//TODO: endpoint
|
||||
break
|
||||
case "ServiceRequest":
|
||||
// basedOn[x] can contain
|
||||
//- CarePlan | ServiceRequest | MedicationRequest
|
||||
resourceRaw.basedOn?.map((basedOn) => {
|
||||
resourceRefs.push(basedOn.reference)
|
||||
})
|
||||
|
||||
//requester can contain
|
||||
//- Practitioner
|
||||
//- Organization
|
||||
//- Patient
|
||||
//- PractitionerRole
|
||||
//- RelatedPerson
|
||||
//- Device
|
||||
resourceRefs.push(resourceRaw.requester?.reference)
|
||||
|
||||
//performer[x] can contain
|
||||
//- Practitioner | PractitionerRole | Organization | CareTeam | HealthcareService | Patient | Device | RelatedPerson
|
||||
resourceRaw.performer?.map((performer) => {
|
||||
resourceRefs.push(performer.reference)
|
||||
})
|
||||
|
||||
//locationReference[x] an contain
|
||||
//-Location
|
||||
resourceRaw.locationReference?.map((locationReference) => {
|
||||
resourceRefs.push(locationReference.reference)
|
||||
})
|
||||
|
||||
//reasonReference[x] can contain
|
||||
//-Condition
|
||||
//-Observation
|
||||
//-DiagnosticReport
|
||||
//-DocumentReference
|
||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
||||
resourceRefs.push(reasonReference.reference)
|
||||
})
|
||||
|
||||
//insurance[x] can contain
|
||||
//- Coverage | ClaimResponse
|
||||
resourceRaw.insurance?.map((insurance) => {
|
||||
resourceRefs.push(insurance.reference)
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// remove all null values, remove all duplicates
|
||||
let cleanResourceRefs = resourceRefs.filter(i => !(typeof i === 'undefined' || i === null));
|
||||
cleanResourceRefs = [...new Set(cleanResourceRefs)]
|
||||
return cleanResourceRefs
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to sync all resources from a Bundle file. Not applicable to this Client
|
||||
* @param db
|
||||
* @param bundleFile
|
||||
* @constructor
|
||||
*/
|
||||
public async SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary> {
|
||||
return Promise.reject(new Error("not implemented"));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Protected methods
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
/**
|
||||
* This function attempts to retrieve the Patient Bundle using the Patient-everything api endpoint (if available)
|
||||
* This response may be paginated
|
||||
* https://hl7.org/fhir/operation-patient-everything.html
|
||||
* @param patientId
|
||||
* @constructor
|
||||
* @protected
|
||||
*/
|
||||
protected GetPatientBundle(patientId: string): Promise<any> {
|
||||
return this.GetResourceBundlePaginated(`Patient/${patientId}/$everything`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function retrieves a patient resource
|
||||
* @param patientId
|
||||
* @constructor
|
||||
* @protected
|
||||
*/
|
||||
protected GetPatient(patientId: string): Promise<IResourceRaw> {
|
||||
return this.GetRequest(`Patient/${patientId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function parses a FHIR Bundle and wraps each BundleEntry resource in a ResourceFhir object which can be stored in the DB.
|
||||
* @param bundle
|
||||
* @constructor
|
||||
* @protected
|
||||
*/
|
||||
protected async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
|
||||
// console.log(bundle)
|
||||
// process each entry in bundle
|
||||
return bundle.entry
|
||||
.filter((bundleEntry) => {
|
||||
return bundleEntry.resource.id // keep this entry if it has an ID, skip otherwise.
|
||||
})
|
||||
.map((bundleEntry) => {
|
||||
const wrappedResourceModel = new ResourceFhir()
|
||||
wrappedResourceModel.fhir_version = this.fhirVersion
|
||||
wrappedResourceModel.source_id = this.source._id
|
||||
wrappedResourceModel.source_resource_id = bundleEntry.resource.id
|
||||
wrappedResourceModel.source_resource_type = bundleEntry.resource.resourceType
|
||||
wrappedResourceModel.resource_raw = bundleEntry.resource
|
||||
// TODO find a way to safely/consistently get the resource updated date (and other metadata) which shoudl be added to the model.
|
||||
// wrappedResourceModel.updated_at = bundleEntry.resource.meta?.lastUpdated
|
||||
return wrappedResourceModel
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of resource ids (Patient/xxx, Observation/yyyy), request these resources and generate a pseudo-bundle file
|
||||
* @param resourceIds
|
||||
* @constructor
|
||||
* @protected
|
||||
*/
|
||||
protected async GenerateResourceBundleFromResourceIds(resourceIds: string[]): Promise<IResourceBundleRaw>{
|
||||
|
||||
resourceIds = [...new Set(resourceIds)] //make sure they are unique references.
|
||||
let rawResourceRefs = await Promise.all(resourceIds.map(async (extractedResourceRef) => {
|
||||
return {
|
||||
resource: await this.GetRequest(extractedResourceRef) as IResourceRaw
|
||||
} as IResourceBundleEntryRaw
|
||||
}))
|
||||
|
||||
return {resourceType: "Bundle", entry: rawResourceRefs}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a resource bundle. While "next" link is present in response, continue to request urls and append BundleEntries
|
||||
* @param relativeResourcePath
|
||||
* @constructor
|
||||
* @private
|
||||
*/
|
||||
protected async GetResourceBundlePaginated(relativeResourcePath: string): Promise<IResourceBundleRaw> {
|
||||
// https://www.hl7.org/fhir/patient-operation-everything.html
|
||||
|
||||
const bundle = await this.GetRequest(relativeResourcePath) as IResourceBundleRaw
|
||||
|
||||
let next: string
|
||||
let prev: string
|
||||
let self: string
|
||||
for(let link of bundle.link || []){
|
||||
if(link.relation == "next"){
|
||||
next = link.url
|
||||
} else if(link.relation == "self"){
|
||||
self = link.url
|
||||
} else if(link.relation == "previous"){
|
||||
prev = link.url
|
||||
}
|
||||
}
|
||||
|
||||
while(next && next != self && next != prev){
|
||||
console.debug(`Paginated request => ${next}`)
|
||||
let nextBundle = await this.GetRequest(next) as IResourceBundleRaw
|
||||
bundle.entry = bundle.entry.concat(nextBundle.entry)
|
||||
|
||||
next = "" //reset the pointers
|
||||
self = ""
|
||||
prev = ""
|
||||
for(let link of nextBundle.link){
|
||||
if(link.relation == "next"){
|
||||
next = link.url
|
||||
} else if(link.relation == "self"){
|
||||
self = link.url
|
||||
} else if(link.relation == "previous"){
|
||||
prev = link.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Private methods
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,159 +0,0 @@
|
|||
{
|
||||
"resourceType": "Patient",
|
||||
"id": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6",
|
||||
"meta": {
|
||||
"versionId": "1.0"
|
||||
},
|
||||
"extension": [
|
||||
{
|
||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race",
|
||||
"extension": [
|
||||
{
|
||||
"url": "ombCategory",
|
||||
"valueCoding": {
|
||||
"system": "urn:oid:2.16.840.1.113883.6.238",
|
||||
"code": "2054-5",
|
||||
"display": "Black or African American"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "text",
|
||||
"valueString": "Black or African American"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity",
|
||||
"extension": [
|
||||
{
|
||||
"url": "ombCategory",
|
||||
"valueCoding": {
|
||||
"system": "urn:oid:2.16.840.1.113883.6.238",
|
||||
"code": "2135-2",
|
||||
"display": "Hispanic or Latino"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "text",
|
||||
"valueString": "Hispanic or Latino"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName",
|
||||
"valueString": "Ramona980 Franco581"
|
||||
},
|
||||
{
|
||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex",
|
||||
"valueCode": "M"
|
||||
},
|
||||
{
|
||||
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
|
||||
"valueCode": "masked"
|
||||
}
|
||||
],
|
||||
"identifier": [
|
||||
{
|
||||
"system": "https://github.com/synthetichealth/synthea",
|
||||
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
|
||||
},
|
||||
{
|
||||
"type": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
||||
"code": "MR",
|
||||
"display": "Medical Record Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"system": "http://hospital.smarthealthit.org",
|
||||
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
|
||||
},
|
||||
{
|
||||
"type": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
||||
"code": "SS",
|
||||
"display": "Social Security Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"system": "http://hl7.org/fhir/sid/us-ssn",
|
||||
"value": "999-50-6731"
|
||||
},
|
||||
{
|
||||
"type": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
||||
"code": "DL",
|
||||
"display": "Drivers License"
|
||||
}
|
||||
]
|
||||
},
|
||||
"system": "urn:oid:2.16.840.1.113883.4.3.25",
|
||||
"value": "S99957478"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"use": "official",
|
||||
"family": "Gutiérrez115",
|
||||
"given": [
|
||||
"Hugo693"
|
||||
],
|
||||
"prefix": [
|
||||
"Mr."
|
||||
]
|
||||
}
|
||||
],
|
||||
"telecom": [
|
||||
{
|
||||
"system": "phone",
|
||||
"value": "555-914-6475",
|
||||
"use": "home"
|
||||
}
|
||||
],
|
||||
"gender": "male",
|
||||
"birthDate": "1969-01-12",
|
||||
"address": [
|
||||
{
|
||||
"line": [
|
||||
"[\"293 Douglas Ramp\"]"
|
||||
],
|
||||
"city": "Natick",
|
||||
"state": "MA",
|
||||
"postalCode": "01999",
|
||||
"country": "US",
|
||||
"period": {
|
||||
"start": "1969-01-12T00:00:00+00:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"maritalStatus": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus",
|
||||
"code": "M",
|
||||
"display": "M"
|
||||
}
|
||||
],
|
||||
"text": "M"
|
||||
},
|
||||
"multipleBirthBoolean": false,
|
||||
"communication": [
|
||||
{
|
||||
"language": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "urn:ietf:bcp:47",
|
||||
"code": "es",
|
||||
"display": "Spanish"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"resourceType": "CarePlan",
|
||||
"id": "197543976",
|
||||
"meta": {
|
||||
"versionId": "1",
|
||||
"lastUpdated": "2021-10-04T14:46:33.000Z"
|
||||
},
|
||||
"text": {
|
||||
"status": "additional",
|
||||
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Care Plan</b></p><p><b>Patient</b>: SMART, NANCYU NHA EEEEEE</p><p><b>Title</b>: Comorbidities found via Retrieve Dx</p><p><b>Description</b>: Malnutrition:Severe --- Inputs [Height: 165.1, Weight: 25, BMI (Calculated): 9.171615976368047]\n\nAnoxic Brain Injury\n\n</p><p><b>Status</b>: Active</p><p><b>Intent</b>: Plan</p><p><b>Category</b>: Assessment and Plan of Treatment</p><p><b>Author</b>: P., MD, Cardio</p><p><b>Effective Period</b>: Oct 4, 2021 2:46 P.M. UTC</p></div>"
|
||||
},
|
||||
"status": "active",
|
||||
"intent": "plan",
|
||||
"category": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/us/core/CodeSystem/careplan-category",
|
||||
"code": "assess-plan",
|
||||
"display": "Assessment and Plan of Treatment"
|
||||
}
|
||||
],
|
||||
"text": "Assessment and Plan of Treatment"
|
||||
}
|
||||
],
|
||||
"title": "Comorbidities found via Retrieve Dx",
|
||||
"subject": {
|
||||
"reference": "Patient/12724066",
|
||||
"display": "SMART, NANCYU NHA EEEEEE"
|
||||
},
|
||||
"encounter": {
|
||||
"reference": "Encounter/97961321"
|
||||
},
|
||||
"period": {
|
||||
"start": "2021-10-04T14:46:33.000Z"
|
||||
},
|
||||
"author": {
|
||||
"reference": "Practitioner/12763770",
|
||||
"display": "P., MD, Cardio"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,26 +0,0 @@
|
|||
import {IClient} from '../interface';
|
||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
||||
import {Source} from '../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../database/interface';
|
||||
import {UpsertSummary} from '../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../models/client/client-config';
|
||||
|
||||
export class BlueButtonClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* BlueButton overrides the SyncAll function because Patient-everything operation is not available.
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const supportedResources: string[] = [
|
||||
"ExplanationOfBenefit",
|
||||
"Coverage",
|
||||
]
|
||||
|
||||
return this.SyncAllByResourceName(db, supportedResources)
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import {IClient} from '../interface';
|
||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
||||
import {Source} from '../../models/database/source';
|
||||
import {ClientConfig} from '../../models/client/client-config';
|
||||
|
||||
export class CignaClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class CareEvolutionClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
//CareEvolution API requires the following Accept header for every request
|
||||
this.headers.set("Accept","application/json+fhir")
|
||||
}
|
||||
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class CernerClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
//Cerner API requires the following Accept header for every request
|
||||
this.headers.set("Accept","application/json+fhir")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cerner overrides the SyncAll function because Patient-everything operation is not available.
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const supportedResources: string[] = this.usCoreResources.concat([
|
||||
"Account",
|
||||
"Appointment",
|
||||
"Consent",
|
||||
"FamilyMemberHistory",
|
||||
"InsurancePlan",
|
||||
"MedicationRequest",
|
||||
"NutritionOrder",
|
||||
"Person",
|
||||
"Provenance",
|
||||
"Questionnaire",
|
||||
"QuestionnaireResponse",
|
||||
"RelatedPerson",
|
||||
"Schedule",
|
||||
"ServiceRequest",
|
||||
"Slot",
|
||||
])
|
||||
|
||||
return this.SyncAllByResourceName(db, supportedResources)
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class EpicClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
//Epic API requires the following Accept header for every request
|
||||
this.headers.set("Accept","application/json+fhir")
|
||||
}
|
||||
|
||||
/**
|
||||
* Epic overrides the SyncAll function because Patient-everything operation is not available.
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const supportedResources: string[] = this.usCoreResources.concat([
|
||||
"Consent",
|
||||
"FamilyMemberHistory",
|
||||
"InsurancePlan",
|
||||
"MedicationRequest",
|
||||
"NutritionOrder",
|
||||
"Person",
|
||||
"Provenance",
|
||||
"Questionnaire",
|
||||
"QuestionnaireResponse",
|
||||
"RelatedPerson",
|
||||
"Schedule",
|
||||
"ServiceRequest",
|
||||
"Slot",
|
||||
])
|
||||
|
||||
return this.SyncAllByResourceName(db, supportedResources)
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class AthenaClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Athena overrides the SyncAll function because Patient-everything operation is not available.
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const supportedResources: string[] = this.usCoreResources
|
||||
|
||||
return this.SyncAllByResourceName(db, supportedResources)
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class HealthITClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
//HealthIT API requires the following Accept header for every request
|
||||
this.headers.set("Accept","application/json+fhir")
|
||||
}
|
||||
|
||||
/**
|
||||
* HealthIT overrides the SyncAll function because Patient-everything operation is not available.
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
||||
const supportedResources: string[] = this.usCoreResources.concat([
|
||||
"Consent",
|
||||
"FamilyMemberHistory",
|
||||
"InsurancePlan",
|
||||
"MedicationRequest",
|
||||
"NutritionOrder",
|
||||
"Person",
|
||||
"Provenance",
|
||||
"Questionnaire",
|
||||
"QuestionnaireResponse",
|
||||
"RelatedPerson",
|
||||
"Schedule",
|
||||
"ServiceRequest",
|
||||
"Slot",
|
||||
])
|
||||
|
||||
return this.SyncAllByResourceName(db, supportedResources)
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import {IClient} from '../../interface';
|
||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
||||
import {Source} from '../../../models/database/source';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {ClientConfig} from '../../../models/client/client-config';
|
||||
|
||||
export class LogicaClient extends FHIR401Client implements IClient {
|
||||
constructor(source: Source, clientConfig: ClientConfig) {
|
||||
super(source, clientConfig);
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import {IDatabaseRepository} from '../database/interface';
|
||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
||||
import {Source} from '../models/database/source';
|
||||
|
||||
export interface IClient {
|
||||
fhirVersion: string
|
||||
source: Source
|
||||
GetRequest(resourceSubpath: string): Promise<any>
|
||||
GetFhirVersion(): Promise<any>
|
||||
RefreshSourceToken(): Promise<boolean>
|
||||
|
||||
/**
|
||||
* This function attempts to retrieve a Patient Bundle and sync all resources to the database
|
||||
* @param db
|
||||
* @constructor
|
||||
*/
|
||||
SyncAll(db: IDatabaseRepository): Promise<UpsertSummary>
|
||||
|
||||
|
||||
SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>
|
||||
|
||||
//Manual client ONLY functions
|
||||
SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary>
|
||||
}
|
||||
|
||||
//This is the "raw" Fhir resource
|
||||
export interface IResourceRaw {
|
||||
resourceType: string
|
||||
id?: string
|
||||
meta?: IResourceMetaRaw
|
||||
}
|
||||
// This is the "raw" Fhir Bundle resource
|
||||
export interface IResourceBundleRaw {
|
||||
resourceType: string
|
||||
id?: string
|
||||
entry: IResourceBundleEntryRaw[]
|
||||
total?: number
|
||||
link?: IResourceBundleLinkRaw[]
|
||||
meta?: IResourceMetaRaw
|
||||
}
|
||||
|
||||
export interface IResourceBundleLinkRaw {
|
||||
id?: string
|
||||
relation: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface IResourceBundleEntryRaw {
|
||||
id?: string
|
||||
fullUrl?: string
|
||||
resource: IResourceRaw
|
||||
}
|
||||
|
||||
export interface IResourceMetaRaw {
|
||||
id?: string
|
||||
versionId?: string
|
||||
lastUpdated: string
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export enum DocType {
|
||||
Source = "source",
|
||||
ResourceFhir = "resource_fhir"
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import {Source} from '../models/database/source';
|
||||
import {ResourceFhir} from '../models/database/resource_fhir';
|
||||
import {SourceSummary} from '../models/fasten/source-summary';
|
||||
import {Summary} from '../models/fasten/summary';
|
||||
import {User} from '../models/fasten/user';
|
||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
||||
// import {SourceSummary} from '../../app/models/fasten/source-summary';
|
||||
|
||||
export interface IDatabaseDocument {
|
||||
_id?: string
|
||||
_rev?: string
|
||||
doc_type: string
|
||||
updated_at?: string
|
||||
|
||||
populateId(): void
|
||||
base64Id(): string
|
||||
}
|
||||
|
||||
export interface IDatabasePaginatedResponse {
|
||||
offset: number,
|
||||
total_rows: number,
|
||||
rows: any[]
|
||||
}
|
||||
export interface IDatabaseRepository {
|
||||
GetDB(skipEncryption?: boolean): any
|
||||
Close(): void
|
||||
|
||||
// CreateUser(*models.User) error
|
||||
// GetUserByEmail(context.Context, string) (*models.User, error)
|
||||
// GetCurrentUser(context.Context) *models.User
|
||||
|
||||
UpsertSource(source: Source): Promise<UpsertSummary>
|
||||
GetSource(source_id: string): Promise<Source>
|
||||
DeleteSource(source_id: string): Promise<boolean>
|
||||
GetSourceSummary(source_id: string): Promise<SourceSummary>
|
||||
GetSources(): Promise<IDatabasePaginatedResponse>
|
||||
IsDatabasePopulated(): Promise<boolean>
|
||||
|
||||
// UpsertResource(context.Context, *models.ResourceFhir) error
|
||||
// GetResourceBySourceType(context.Context, string, string) (*models.ResourceFhir, error)
|
||||
// GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error)
|
||||
// ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error)
|
||||
// GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
|
||||
UpsertResource(resource: ResourceFhir): Promise<UpsertSummary>
|
||||
UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary>
|
||||
GetResource(resource_id: string): Promise<ResourceFhir>
|
||||
GetResources(): Promise<IDatabasePaginatedResponse>
|
||||
GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse>
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
// This is a Typescript module that recreates the functionality defined in https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
||||
// This file only exists because the PouchDB crypto plugin must work in both the browser and web-worker environment (where `window` is
|
||||
// undefined and causes errors).
|
||||
// Also, crypto-pouch does not support storing encrypted data in the remote database by default, which I'm attempting to do by commenting out the
|
||||
// NO_COUCH error.
|
||||
//
|
||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
||||
// failed for a variety of reasons, so instead we're using a PouchdbCrypto class with static methods to re-implement the crypto logic
|
||||
//
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
||||
|
||||
import * as Crypt from 'garbados-crypt';
|
||||
import { openDB, deleteDB, wrap, unwrap } from 'idb';
|
||||
|
||||
// const Crypt = require()
|
||||
|
||||
const LOCAL_ID = '_local/crypto'
|
||||
const IGNORE = ['_id', '_rev', '_deleted', '_conflicts']
|
||||
|
||||
// const NO_COUCH = 'crypto-pouch does not work with pouchdb\'s http adapter. Use a local adapter instead.'
|
||||
|
||||
export class PouchdbCryptoOptions {
|
||||
ignore?: string[]
|
||||
}
|
||||
|
||||
export class PouchdbCryptConfig {
|
||||
username: string
|
||||
key: string
|
||||
config: string
|
||||
}
|
||||
|
||||
|
||||
export class PouchdbCrypto {
|
||||
private static async localIdb(){
|
||||
const dbPromise = openDB('crypto-store', 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('crypto');
|
||||
},
|
||||
});
|
||||
|
||||
return await dbPromise
|
||||
}
|
||||
|
||||
|
||||
public static async CryptConfig(key: string, username): Promise<PouchdbCryptConfig>{
|
||||
const _crypt = new Crypt(key)
|
||||
let exportString = await _crypt.export()
|
||||
return {username: username, key: key, config: exportString}
|
||||
}
|
||||
|
||||
public static async StoreCryptConfig(cryptConfig: PouchdbCryptConfig) {
|
||||
const localDb = await PouchdbCrypto.localIdb()
|
||||
await localDb.put('crypto', JSON.stringify(cryptConfig), `encryption_data_${cryptConfig.username}`)
|
||||
}
|
||||
|
||||
public static async RetrieveCryptConfig(currentUser: string): Promise<PouchdbCryptConfig>{
|
||||
const localDb = await PouchdbCrypto.localIdb()
|
||||
let cryptConfigStr = await localDb.get('crypto',`encryption_data_${currentUser}`)
|
||||
if(!cryptConfigStr){
|
||||
throw new Error("crypto configuration not set")
|
||||
}
|
||||
return JSON.parse(cryptConfigStr) as PouchdbCryptConfig
|
||||
}
|
||||
public static async DeleteCryptConfig(currentUser: string): Promise<void>{
|
||||
const localDb = await PouchdbCrypto.localIdb()
|
||||
return await localDb.delete('crypto',`encryption_data_${currentUser}`)
|
||||
}
|
||||
|
||||
|
||||
public static async crypto(db, cryptConfig: PouchdbCryptConfig, options: PouchdbCryptoOptions = {}) {
|
||||
// if (db.adapter === 'http') {
|
||||
// throw new Error(NO_COUCH)
|
||||
// }
|
||||
// if (typeof password === 'object') {
|
||||
// // handle `db.crypto({ password, ...options })`
|
||||
// options = password
|
||||
// password = password.password
|
||||
// delete options.password
|
||||
// }
|
||||
// setup ignore list
|
||||
db._ignore = IGNORE.concat(options.ignore || [])
|
||||
|
||||
if(!cryptConfig || !cryptConfig.key || !cryptConfig.config){
|
||||
throw new Error("crypto configuration file is required")
|
||||
}
|
||||
|
||||
// setup crypto helper
|
||||
const trySetup = async () => {
|
||||
// try saving credentials to a local doc
|
||||
try {
|
||||
// // first we try to get saved creds from the local doc
|
||||
// const localDb = await PouchdbCrypto.localIdb()
|
||||
// let exportString = await localDb.get('crypto',`encryption_data_${db.name}`)
|
||||
// if(!exportString){
|
||||
// // no existing encryption key found
|
||||
//
|
||||
// // do first-time setup
|
||||
// db._crypt = new Crypt(password)
|
||||
// let exportString = await db._crypt.export()
|
||||
// await localDb.put('crypto', exportString, `encryption_data_${db.name}`)
|
||||
// } else {
|
||||
//
|
||||
// }
|
||||
|
||||
db._crypt = await Crypt.import(cryptConfig.key, cryptConfig.config)
|
||||
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
await trySetup()
|
||||
// instrument document transforms
|
||||
db.transform({
|
||||
incoming: async (doc) => {
|
||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
||||
if (!db._crypt) {
|
||||
return doc
|
||||
}
|
||||
if (doc._attachments && !db._ignore.includes('_attachments')) {
|
||||
throw new Error('Attachments cannot be encrypted. Use {ignore: "_attachments"} option')
|
||||
}
|
||||
let encrypted: any = {}
|
||||
for (let key of db._ignore) {
|
||||
// attach ignored fields to encrypted doc
|
||||
if (key in doc) encrypted[key] = doc[key]
|
||||
}
|
||||
encrypted.payload = await db._crypt.encrypt(JSON.stringify(doc))
|
||||
return encrypted
|
||||
},
|
||||
outgoing: async (doc) => {
|
||||
return await this.decryptDocument(db, doc)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
public static async decryptDocument(db, doc):Promise<any>{
|
||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
||||
if (!db._crypt) { return doc }
|
||||
let decryptedString = await db._crypt.decrypt(doc.payload)
|
||||
let decrypted = JSON.parse(decryptedString)
|
||||
for (let key of db._ignore) {
|
||||
// patch decrypted doc with ignored fields
|
||||
if (key in doc) decrypted[key] = doc[key]
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
public static removeCrypto(db) {
|
||||
delete db._crypt
|
||||
}
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
// This is a Typescript module that recreates the functionality defined in https://github.com/pouchdb/upsert/blob/master/index.js
|
||||
// This file only exists because the PouchDB upsert plugin must work in both the browser and web-worker environment (where `window` is
|
||||
// undefined and causes errors).
|
||||
//
|
||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
||||
// failed for a variety of reasons, so instead we're using a PouchdbUpsert class with static methods to re-implement the upsert logic
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/pouchdb/upsert/blob/master/index.js
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
||||
export class PouchdbUpsert {
|
||||
public static upsert(db, docId, diffFun, cb?) {
|
||||
var promise = PouchdbUpsert.upsertInner(db, docId, diffFun);
|
||||
if (typeof cb !== 'function') {
|
||||
return promise;
|
||||
}
|
||||
promise.then(function(resp) {
|
||||
cb(null, resp);
|
||||
}, cb);
|
||||
};
|
||||
|
||||
public static putIfNotExists(db, docId, doc, cb?) {
|
||||
if (typeof docId !== 'string') {
|
||||
cb = doc;
|
||||
doc = docId;
|
||||
docId = doc._id;
|
||||
}
|
||||
|
||||
var diffFun = function(existingDoc) {
|
||||
if (existingDoc._rev) {
|
||||
return false; // do nothing
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
|
||||
var promise = PouchdbUpsert.upsertInner(db, docId, diffFun);
|
||||
if (typeof cb !== 'function') {
|
||||
return promise;
|
||||
}
|
||||
promise.then(function(resp) {
|
||||
cb(null, resp);
|
||||
}, cb);
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// private methods
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// this is essentially the "update sugar" function from daleharvey/pouchdb#1388
|
||||
// the diffFun tells us what delta to apply to the doc. it either returns
|
||||
// the doc, or false if it doesn't need to do an update after all
|
||||
private static upsertInner(db, docId, diffFun) {
|
||||
if (typeof docId !== 'string') {
|
||||
return Promise.reject(new Error('doc id is required'));
|
||||
}
|
||||
|
||||
return db.get(docId).catch(function (err) {
|
||||
/* istanbul ignore next */
|
||||
if (err.status !== 404) {
|
||||
throw err;
|
||||
}
|
||||
return {};
|
||||
}).then(function (doc) {
|
||||
// the user might change the _rev, so save it for posterity
|
||||
var docRev = doc._rev;
|
||||
var newDoc = diffFun(doc);
|
||||
|
||||
if (!newDoc) {
|
||||
// if the diffFun returns falsy, we short-circuit as
|
||||
// an optimization
|
||||
return { updated: false, rev: docRev, id: docId };
|
||||
}
|
||||
|
||||
// users aren't allowed to modify these values,
|
||||
// so reset them here
|
||||
newDoc._id = docId;
|
||||
newDoc._rev = docRev;
|
||||
return PouchdbUpsert.tryAndPut(db, newDoc, diffFun);
|
||||
});
|
||||
}
|
||||
|
||||
private static tryAndPut(db, doc, diffFun) {
|
||||
return db.put(doc).then((res) => {
|
||||
return {
|
||||
updated: true,
|
||||
rev: res.rev,
|
||||
id: doc._id
|
||||
};
|
||||
}, (err) => {
|
||||
/* istanbul ignore next */
|
||||
if (err.status !== 409) {
|
||||
throw err;
|
||||
}
|
||||
return this.upsertInner(db, doc._id, diffFun);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
import {IDatabaseRepository} from './interface';
|
||||
import {NewPouchdbRepositoryWebWorker} from './pouchdb_repository';
|
||||
import {SourceType} from '../models/database/source_types';
|
||||
import {Source} from '../models/database/source';
|
||||
import {DocType} from './constants';
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {PouchdbCrypto} from './plugins/crypto';
|
||||
|
||||
describe('PouchdbRepository', () => {
|
||||
let repository: IDatabaseRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
let current_user = uuidv4() as string
|
||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
repository = NewPouchdbRepositoryWebWorker({current_user: current_user, auth_token: ""}, '/database', new PouchDB("PouchdbRepository" + current_user));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if(repository){
|
||||
const db = await repository.GetDB()
|
||||
db.destroy() //wipe the db.
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('should be created', () => {
|
||||
expect(repository).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('CreateSource', () => {
|
||||
it('should return an id', async () => {
|
||||
const createdId = await repository.UpsertSource(new Source({
|
||||
patient: 'patient',
|
||||
source_type: SourceType.Aetna,
|
||||
}))
|
||||
|
||||
expect(createdId.totalResources).toEqual(1);
|
||||
expect(createdId.updatedResources[0]).toEqual("source:aetna:patient");
|
||||
});
|
||||
})
|
||||
|
||||
describe('GetSource', () => {
|
||||
it('should return an source', async () => {
|
||||
const createdResource = await repository.UpsertSource(new Source({
|
||||
patient: 'patient',
|
||||
source_type: SourceType.Aetna,
|
||||
access_token: 'hello-world',
|
||||
}))
|
||||
|
||||
const createdSource = await repository.GetSource(createdResource.updatedResources[0])
|
||||
|
||||
expect(createdSource.doc_type).toEqual(DocType.Source);
|
||||
expect(createdSource.patient).toEqual('patient');
|
||||
expect(createdSource.source_type).toEqual(SourceType.Aetna);
|
||||
expect(createdSource.access_token).toEqual('hello-world');
|
||||
});
|
||||
})
|
||||
|
||||
describe('DeleteSource', () => {
|
||||
it('should delete a source', async () => {
|
||||
const createdResource = await repository.UpsertSource(new Source({
|
||||
patient: 'patient-to-delete',
|
||||
source_type: SourceType.Aetna,
|
||||
access_token: 'hello-world',
|
||||
}))
|
||||
console.log(createdResource)
|
||||
const deletedSource = await repository.DeleteSource(createdResource.updatedResources[0])
|
||||
|
||||
expect(deletedSource).toBeTruthy();
|
||||
});
|
||||
})
|
||||
|
||||
describe('GetSources', () => {
|
||||
it('should return a list of sources', async () => {
|
||||
await repository.UpsertSource(new Source({
|
||||
patient: 'patient1',
|
||||
source_type: SourceType.Aetna,
|
||||
access_token: 'hello-world1',
|
||||
}))
|
||||
|
||||
await repository.UpsertSource(new Source({
|
||||
patient: 'patient2',
|
||||
source_type: SourceType.Aetna,
|
||||
access_token: 'hello-world2',
|
||||
}))
|
||||
|
||||
await repository.UpsertSource(new Source({
|
||||
patient: 'patient3',
|
||||
source_type: SourceType.Aetna,
|
||||
access_token: 'hello-world3',
|
||||
}))
|
||||
|
||||
const sourcesWrapped = await repository.GetSources()
|
||||
|
||||
expect(sourcesWrapped.total_rows).toEqual(3);
|
||||
expect((sourcesWrapped.rows[0] as Source).patient).toEqual('patient1');
|
||||
});
|
||||
})
|
||||
});
|
|
@ -1,465 +0,0 @@
|
|||
import {Source} from '../models/database/source';
|
||||
import {IDatabasePaginatedResponse, IDatabaseDocument, IDatabaseRepository} from './interface';
|
||||
import {DocType} from './constants';
|
||||
import {ResourceFhir} from '../models/database/resource_fhir';
|
||||
import {ResourceTypeCounts, SourceSummary} from '../models/fasten/source-summary';
|
||||
import {Base64} from '../utils/base64';
|
||||
import {GetEndpointAbsolutePath} from '../utils/endpoint_absolute_path';
|
||||
|
||||
// PouchDB & plugins
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
|
||||
// import * as PouchCrypto from 'crypto-pouch';
|
||||
// PouchDB.plugin(PouchCrypto);
|
||||
|
||||
import * as PouchTransform from 'transform-pouch';
|
||||
PouchDB.plugin(PouchTransform);
|
||||
|
||||
import {PouchdbUpsert} from './plugins/upsert';
|
||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
||||
|
||||
import {PouchdbCryptConfig, PouchdbCrypto, PouchdbCryptoOptions} from './plugins/crypto';
|
||||
import {utils} from 'protractor';
|
||||
|
||||
// !!!!!!!!!!!!!!!!WARNING!!!!!!!!!!!!!!!!!!!!!
|
||||
// most pouchdb plugins seem to fail when used in a webworker.
|
||||
// !!!!!!!!!!!!!!!!WARNING!!!!!!!!!!!!!!!!!!!!!
|
||||
// import * as PouchUpsert from 'pouchdb-upsert';
|
||||
// PouchDB.plugin(PouchUpsert);
|
||||
// import find from 'pouchdb-find';
|
||||
// PouchDB.plugin(find);
|
||||
// PouchDB.debug.enable('pouchdb:find')
|
||||
|
||||
// import * as rawUpsert from 'pouchdb-upsert';
|
||||
// const upsert: PouchDB.Plugin = (rawUpsert as any);
|
||||
// PouchDB.plugin(upsert);
|
||||
|
||||
// import {PouchdbUpsert} from './plugins/upsert';
|
||||
// const upsert = new PouchdbUpsert()
|
||||
// console.log("typeof PouchdbUpsert",typeof upsert, upsert)
|
||||
// PouchDB.plugin(upsert.default)
|
||||
|
||||
// YOU MUST USE globalThis not window or self.
|
||||
// YOU MUST NOT USE console.* as its not available in a webworker context
|
||||
|
||||
|
||||
// this is required, otherwise PouchFind fails when looking for the global PouchDB variable
|
||||
|
||||
/**
|
||||
* This method is used to initialize the repository from Workers.
|
||||
* Eventually this method should dyanmically dtermine the version of the repo to return from the env.
|
||||
* @constructor
|
||||
*/
|
||||
|
||||
export function NewPouchdbRepositoryWebWorker(auth: {current_user: string, auth_token: string}, couchDbEndpointBase: string, localPouchDb?: PouchDB.Database): PouchdbRepository {
|
||||
let pouchdbRepository = new PouchdbRepository(couchDbEndpointBase, localPouchDb)
|
||||
pouchdbRepository.current_user = auth.current_user
|
||||
pouchdbRepository._auth_token = auth.auth_token
|
||||
return pouchdbRepository
|
||||
}
|
||||
export class PouchdbRepository implements IDatabaseRepository {
|
||||
|
||||
// replicationHandler: any
|
||||
remotePouchEndpoint: string // "http://localhost:5984"
|
||||
pouchDb: PouchDB.Database
|
||||
current_user: string
|
||||
_auth_token: string
|
||||
|
||||
//encryption configuration
|
||||
cryptConfig: PouchdbCryptConfig = null
|
||||
encryptionInitComplete: boolean = false
|
||||
|
||||
// There are 3 different ways to initialize the Database
|
||||
// - explicitly after signin/signup (not supported by this class, see FastenDbService)
|
||||
// - explicitly during web-worker init
|
||||
// - implicitly after Lighthouse redirect (not supported by this class, see FastenDbService)
|
||||
// Three peices of information are required during intialization
|
||||
// - couchdb endpoint (constant, see environment.couchdb_endpoint_base)
|
||||
// - username
|
||||
// - JWT token
|
||||
constructor(couchDbEndpointBase: string, localPouchDb?: PouchDB.Database) {
|
||||
// couchDbEndpointBase could be a relative or absolute path.
|
||||
//if its absolute, we should pass it in, as-is
|
||||
this.remotePouchEndpoint = GetEndpointAbsolutePath(globalThis.location, couchDbEndpointBase)
|
||||
|
||||
|
||||
//setup PouchDB Plugins
|
||||
//https://pouchdb.com/guides/mango-queries.html
|
||||
this.pouchDb = null
|
||||
|
||||
if(localPouchDb){
|
||||
console.warn("using local pouchdb, this should only be used for testing")
|
||||
this.pouchDb = localPouchDb
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Teardown / deconfigure the existing database instance (if there is one).
|
||||
// --
|
||||
// CAUTION: Subsequent calls to .GetDB() will fail until a new instance is configured
|
||||
// with a call to .ConfigureForUser().
|
||||
public async Close(): Promise<void> {
|
||||
if (!this.pouchDb) {
|
||||
return;
|
||||
}
|
||||
// Stop remote replication for existing database
|
||||
// if(this.replicationHandler){
|
||||
// this.replicationHandler.cancel()
|
||||
// }
|
||||
|
||||
this.pouchDb.close();
|
||||
this.pouchDb = null;
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Source
|
||||
|
||||
public async UpsertSource(source: Source): Promise<UpsertSummary> {
|
||||
return this.upsertDocument(source);
|
||||
}
|
||||
|
||||
public async GetSource(source_id: string): Promise<Source> {
|
||||
return this.getDocument(source_id)
|
||||
.then((doc) => {
|
||||
return new Source(doc)
|
||||
})
|
||||
}
|
||||
|
||||
public async GetSources(): Promise<IDatabasePaginatedResponse> {
|
||||
return this.findDocumentByDocType(DocType.Source)
|
||||
.then((docWrapper) => {
|
||||
|
||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
||||
return new Source(result.doc)
|
||||
})
|
||||
return docWrapper
|
||||
})
|
||||
}
|
||||
|
||||
public async GetSourceSummary(source_id: string): Promise<SourceSummary> {
|
||||
const sourceSummary = new SourceSummary()
|
||||
sourceSummary.source = await this.GetSource(source_id)
|
||||
sourceSummary.patient = await this.findDocumentByPrefix(`${DocType.ResourceFhir}:${Base64.Encode(source_id)}:Patient`, true)
|
||||
.then((paginatedResp) => new ResourceFhir(paginatedResp?.rows[0].doc))
|
||||
|
||||
sourceSummary.resource_type_counts = await this.findDocumentByPrefix(`${DocType.ResourceFhir}:${Base64.Encode(source_id)}`, false)
|
||||
.then((paginatedResp) => {
|
||||
const lookup: {[name: string]: ResourceTypeCounts} = {}
|
||||
paginatedResp?.rows.forEach((resourceWrapper) => {
|
||||
const resourceIdParts = resourceWrapper.id.split(':')
|
||||
const resourceType = resourceIdParts[2]
|
||||
|
||||
let currentResourceStats = lookup[resourceType] || {
|
||||
count: 0,
|
||||
source_id: Base64.Decode(resourceIdParts[1]),
|
||||
resource_type: resourceType
|
||||
}
|
||||
currentResourceStats.count += 1
|
||||
lookup[resourceType] = currentResourceStats
|
||||
})
|
||||
|
||||
const arr = []
|
||||
for(let key in lookup){
|
||||
arr.push(lookup[key])
|
||||
}
|
||||
return arr
|
||||
})
|
||||
return sourceSummary
|
||||
}
|
||||
|
||||
public async DeleteSource(source_id: string): Promise<boolean> {
|
||||
return this.deleteDocument(source_id)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Resource
|
||||
|
||||
public async UpsertResource(resource: ResourceFhir): Promise<UpsertSummary> {
|
||||
return this.upsertDocument(resource);
|
||||
}
|
||||
|
||||
public async UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary> {
|
||||
return this.upsertBulk(resources);
|
||||
}
|
||||
|
||||
public async GetResource(resource_id: string): Promise<ResourceFhir> {
|
||||
return this.getDocument(resource_id)
|
||||
.then((doc) => {
|
||||
return new ResourceFhir(doc)
|
||||
})
|
||||
}
|
||||
|
||||
public async GetResources(): Promise<IDatabasePaginatedResponse> {
|
||||
return this.findDocumentByDocType(DocType.ResourceFhir)
|
||||
.then((docWrapper) => {
|
||||
|
||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
||||
return new ResourceFhir(result.doc)
|
||||
})
|
||||
return docWrapper
|
||||
})
|
||||
}
|
||||
|
||||
public async GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse> {
|
||||
let prefix = `${DocType.ResourceFhir}:${Base64.Encode(source_id)}`
|
||||
if(source_resource_type){
|
||||
prefix += `:${source_resource_type}`
|
||||
}
|
||||
|
||||
return this.findDocumentByPrefix(prefix, true)
|
||||
.then((docWrapper) => {
|
||||
|
||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
||||
return new ResourceFhir(result.doc)
|
||||
})
|
||||
return docWrapper
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* given an raw connection to a database, determine how many records/resources are stored within
|
||||
* @constructor
|
||||
*/
|
||||
public async IsDatabasePopulated(): Promise<boolean> {
|
||||
let resourceFhirCount = await this.findDocumentByPrefix(DocType.ResourceFhir, false, true)
|
||||
.then((resp) => {
|
||||
console.log("RESPONSE COUNT INFO", resp)
|
||||
return resp.rows.length
|
||||
})
|
||||
|
||||
if(resourceFhirCount > 0) {return true}
|
||||
|
||||
|
||||
let sourceCount = await this.findDocumentByPrefix(DocType.Source, false, true)
|
||||
.then((resp) => {
|
||||
console.log("SOURCE COUNT INFO", resp)
|
||||
|
||||
return resp.rows.length
|
||||
})
|
||||
if(sourceCount > 0) {return true}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// CRUD Operators
|
||||
// All functions below here will return the raw PouchDB responses, and may need to be wrapped in
|
||||
// new ResourceFhir(result.doc)
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
public ResetDB(){
|
||||
this.pouchDb = null
|
||||
this.encryptionInitComplete = false
|
||||
}
|
||||
// Get the active PouchDB instance. Throws an error if no PouchDB instance is
|
||||
// available (ie, user has not yet been configured with call to .configureForUser()).
|
||||
public async GetDB(skipEncryption: boolean = false): Promise<PouchDB.Database> {
|
||||
await this.GetSessionDB()
|
||||
|
||||
if(!this.pouchDb) {
|
||||
throw(new Error( "Database is not available - please configure an instance." ));
|
||||
}
|
||||
|
||||
if(skipEncryption){
|
||||
//this allows the database to be queried, even when encryption has not been configured correctly
|
||||
//this will only be used to take a count of documents in the database, so we can prompt the user for a encryption key, or generate a new one (for an empty db)
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
|
||||
//try to determine the crypto configuration using the currently logged in user.
|
||||
this.cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
||||
|
||||
if(!this.cryptConfig){
|
||||
throw new Error("crypto configuration not set.")
|
||||
}
|
||||
|
||||
if(!this.encryptionInitComplete){
|
||||
return PouchdbCrypto.crypto(this.pouchDb, this.cryptConfig, {ignore:[
|
||||
'doc_type',
|
||||
'source_id',
|
||||
'source_resource_type',
|
||||
'source_resource_id',
|
||||
]})
|
||||
.then((encryptedPouchDb) => {
|
||||
this.pouchDb = encryptedPouchDb
|
||||
this.encryptionInitComplete = true
|
||||
return this.pouchDb
|
||||
})
|
||||
} else {
|
||||
return this.pouchDb;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// update/insert a new document. Returns a promise of the generated id.
|
||||
protected upsertDocument(newDoc: IDatabaseDocument) : Promise<UpsertSummary> {
|
||||
// make sure we always "populate" the ID for every document before submitting
|
||||
newDoc.populateId()
|
||||
|
||||
// NOTE: All friends are given the key-prefix of "friend:". This way, when we go
|
||||
// to query for friends, we can limit the scope to keys with in this key-space.
|
||||
|
||||
return this.GetDB()
|
||||
.then((db) => {
|
||||
return PouchdbUpsert.upsert(db, newDoc._id, (existingDoc: IDatabaseDocument) => {
|
||||
//diffFunc - function that takes the existing doc as input and returns an updated doc.
|
||||
// If this diffFunc returns falsey, then the update won't be performed (as an optimization).
|
||||
// If the document does not already exist, then {} will be the input to diffFunc.
|
||||
|
||||
const isExistingEmpty = Object.keys(existingDoc).length === 0
|
||||
if(isExistingEmpty){
|
||||
//always return new doc (and set update_at if not already set)
|
||||
//if this is a ResourceFhir doc, see if theres a updatedDate already
|
||||
if(newDoc.doc_type == DocType.ResourceFhir){
|
||||
newDoc.updated_at = newDoc.updated_at || (newDoc as any).meta?.updated_at
|
||||
}
|
||||
newDoc.updated_at = newDoc.updated_at || (new Date().toISOString())
|
||||
// console.log("merge, empty")
|
||||
return newDoc
|
||||
}
|
||||
|
||||
if(newDoc.doc_type == DocType.ResourceFhir){
|
||||
|
||||
//for resourceFhir docs, we only care about comparing the resource_raw content
|
||||
const existingContent = JSON.stringify((existingDoc as ResourceFhir).resource_raw)
|
||||
const newContent = JSON.stringify((newDoc as ResourceFhir).resource_raw)
|
||||
if(existingContent == newContent){
|
||||
return false //do not update
|
||||
} else {
|
||||
//theres a difference. Set the updated_at date if possible, otherwise use the current date
|
||||
(newDoc as ResourceFhir).updated_at = (newDoc as any).meta?.updated_at || (new Date().toISOString())
|
||||
return newDoc
|
||||
}
|
||||
|
||||
} else if(newDoc.doc_type == DocType.Source){
|
||||
delete existingDoc._rev
|
||||
const existingContent = JSON.stringify(existingDoc)
|
||||
const newContent = JSON.stringify(newDoc)
|
||||
if(existingContent == newContent){
|
||||
return false //do not update, content is the same for source object
|
||||
} else {
|
||||
//theres a difference. Set the updated_at date
|
||||
(newDoc as Source).updated_at = (new Date().toISOString())
|
||||
return { ...existingDoc, ...newDoc };
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
let errMsg = "unknown doc_type, cannot diff for upsert: " + newDoc.doc_type
|
||||
console.error(errMsg)
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(( result ): UpsertSummary => {
|
||||
// // success, res is {rev: '1-xxx', updated: true, id: 'myDocId'}
|
||||
const updateSummary = new UpsertSummary()
|
||||
updateSummary.totalResources = 1
|
||||
|
||||
if(result.updated){
|
||||
updateSummary.updatedResources = [result.id]
|
||||
}
|
||||
return updateSummary;
|
||||
});
|
||||
}
|
||||
|
||||
protected async upsertBulk(docs: IDatabaseDocument[]): Promise<UpsertSummary> {
|
||||
//insert sequentially (not in parallel)
|
||||
let finalUpsertSummary = new UpsertSummary()
|
||||
|
||||
for (let doc of docs){
|
||||
doc.populateId();
|
||||
let upsertSummary = await this.upsertDocument(doc)
|
||||
finalUpsertSummary.totalResources += upsertSummary.totalResources
|
||||
finalUpsertSummary.updatedResources = finalUpsertSummary.updatedResources.concat(upsertSummary.updatedResources)
|
||||
}
|
||||
|
||||
return finalUpsertSummary
|
||||
}
|
||||
|
||||
protected getDocument(id: string): Promise<any> {
|
||||
return this.GetDB()
|
||||
.then((db) => db.get(id))
|
||||
}
|
||||
|
||||
|
||||
protected findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
||||
return this.findDocumentByPrefix(docType, includeDocs)
|
||||
}
|
||||
protected findDocumentByPrefix(prefix: string, includeDocs: boolean = true, skipEncryption: boolean = false): Promise<IDatabasePaginatedResponse> {
|
||||
return this.GetDB(skipEncryption)
|
||||
.then((db) => {
|
||||
return db.allDocs({
|
||||
include_docs: includeDocs,
|
||||
startkey: `${prefix}:`,
|
||||
endkey: `${prefix}:\uffff`
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
protected async deleteDocument(id: string): Promise<boolean> {
|
||||
const docToDelete = await this.getDocument(id)
|
||||
return this.GetDB()
|
||||
.then((db) => db.remove(docToDelete))
|
||||
.then((result) => {
|
||||
return result.ok
|
||||
})
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Sync private/protected methods
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
/**
|
||||
* Try to get PouchDB database using session information
|
||||
* This method is overridden in PouchDB Service, as session information is inaccessible in web-worker
|
||||
* @constructor
|
||||
*/
|
||||
public async GetSessionDB(): Promise<PouchDB.Database> {
|
||||
if(this.pouchDb){
|
||||
console.log("Session DB already exists..")
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
if(!this.current_user){
|
||||
throw new Error("current user is required when initializing pouchdb within web-worker")
|
||||
}
|
||||
if(!this._auth_token){
|
||||
throw new Error("auth token is required when initializing pouchdb within web-worker")
|
||||
}
|
||||
let auth_token = this._auth_token
|
||||
|
||||
// add JWT bearer token header to all requests
|
||||
// https://stackoverflow.com/questions/62129654/how-to-handle-jwt-authentication-with-rxdb
|
||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(this.current_user), {
|
||||
skip_setup: true,
|
||||
fetch: function (url, opts) {
|
||||
opts.headers.set('Authorization', `Bearer ${auth_token}`)
|
||||
return PouchDB.fetch(url, opts);
|
||||
}
|
||||
})
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
protected getRemoteUserDb(username: string){
|
||||
return `${this.remotePouchEndpoint}/userdb-${this.toHex(username)}`
|
||||
}
|
||||
// protected enableSync(userIdentifier: string){
|
||||
// this.replicationHandler = this.localPouchDb.sync(this.getRemoteUserDb(userIdentifier), {live: true, retry: true})
|
||||
// return
|
||||
// }
|
||||
|
||||
|
||||
private toHex(s: string) {
|
||||
// utf8 to latin1
|
||||
s = unescape(encodeURIComponent(s))
|
||||
let h = ''
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h += s.charCodeAt(i).toString(16)
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
||||
import {SourceType} from '../database/source_types';
|
||||
|
||||
export class ClientConfig {
|
||||
fasten_api_endpoint_base: string
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import {DocType} from '../../database/constants';
|
||||
import {IResourceRaw} from '../../conduit/interface';
|
||||
import {Base64} from '../../utils/base64';
|
||||
|
||||
export class ResourceFhir {
|
||||
_id?: string
|
||||
_rev?: string
|
||||
doc_type: DocType = DocType.ResourceFhir
|
||||
updated_at?: string
|
||||
|
||||
fhir_version: string = ""
|
||||
source_id: string = ""
|
||||
source_resource_type: string = ""
|
||||
source_resource_id: string = ""
|
||||
|
||||
resource_raw?: IResourceRaw
|
||||
|
||||
constructor(object?: any) {
|
||||
if(object){
|
||||
object.doc_type = DocType.ResourceFhir
|
||||
return Object.assign(this, object)
|
||||
} else{
|
||||
this.doc_type = DocType.ResourceFhir
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
populateId(){
|
||||
//TODO: source_id should be base64 encoded (otherwise we get nested : chars)
|
||||
this._id = `${this.doc_type}:${Base64.Encode(this.source_id)}:${this.source_resource_type}:${this.source_resource_id}`
|
||||
}
|
||||
base64Id(): string {
|
||||
this.populateId()
|
||||
return Base64.Encode(this._id)
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
||||
import {SourceType} from './source_types';
|
||||
import {DocType} from '../../../lib/database/constants';
|
||||
import {Base64} from '../../utils/base64';
|
||||
|
||||
export class Source extends LighthouseSourceMetadata{
|
||||
_id?: string
|
||||
_rev?: string
|
||||
doc_type: string
|
||||
updated_at?: string
|
||||
|
||||
source_type: SourceType
|
||||
patient: string
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
expires_at: number //seconds since epoch
|
||||
|
||||
constructor(object: any) {
|
||||
super()
|
||||
object.doc_type = DocType.Source
|
||||
return Object.assign(this, object)
|
||||
}
|
||||
|
||||
populateId(){
|
||||
this._id = `${this.doc_type}:${this.source_type}:${this.patient}`
|
||||
}
|
||||
base64Id(): string {
|
||||
this.populateId()
|
||||
return Base64.Encode(this._id)
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
|
||||
|
||||
export enum SourceType {
|
||||
Manual = "manual",
|
||||
|
||||
Athena = "athena",
|
||||
HealthIT = "healthit",
|
||||
Logica = "logica",
|
||||
|
||||
//platforms
|
||||
CareEvolution = "careevolution",
|
||||
Cerner = "cerner",
|
||||
Epic = "epic",
|
||||
|
||||
//providers
|
||||
Aetna = "aetna",
|
||||
Amerigroup = "amerigroup",
|
||||
AmerigroupMedicaid = "amerigroupmedicaid",
|
||||
Anthem = "anthem",
|
||||
AnthemBluecrossCA = "anthembluecrossca",
|
||||
BlueButton = "bluebutton",
|
||||
BluecrossBlueshieldKansasMedicare = "bcbskansasmedicare",
|
||||
BluecrossBlueshieldKansas = "bcbskansas",
|
||||
BluecrossBlueshieldNY = "bcbsny",
|
||||
BlueMedicareAdvantage = "bluemedicareadvantage",
|
||||
ClearHealthAlliance = "clearhealthalliance",
|
||||
Cigna = "cigna",
|
||||
DellChildrens = "dellchildrens",
|
||||
EmpireBlue = "empireblue",
|
||||
EmpireBlueMedicaid = "empirebluemedicaid",
|
||||
HealthyBlueLA = "healthybluela",
|
||||
HealthyBlueLAMedicaid = "healthybluelamedicaid",
|
||||
HealthyBlueMO = "healthybluemo",
|
||||
HealthyBlueMOMedicaid = "healthybluemomedicaid",
|
||||
HealthyBlueNC = "healthybluenc",
|
||||
HealthyBlueNCMedicaid = "healthybluencmedicaid",
|
||||
HealthyBlueNE = "healthybluene",
|
||||
HealthyBlueSC = "healthybluesc",
|
||||
HighmarkWesternNY = "highmarkwesternny",
|
||||
SimplyHealthcareMedicaid = "simplyhealthcaremedicaid",
|
||||
SimplyHealthcareMedicare = "simplyhealthcaremedicare",
|
||||
SummitCommunityCare = "summitcommunitycare",
|
||||
UCSF = "ucsf-health",
|
||||
Unicare = "unicare",
|
||||
UnicareMA = "unicarema",
|
||||
UnicareMedicaid = "unicaremedicaid",
|
||||
Humana = "humana",
|
||||
Kaiser = "kaiser",
|
||||
UnitedHealthcare = "unitedhealthcare",
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue