begin restoring Sqlite functionality. (#11)

This commit is contained in:
Jason Kulatunga 2022-12-02 19:40:58 -08:00 committed by GitHub
parent 139b483435
commit e360369706
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
107 changed files with 2235 additions and 22602 deletions

View File

@ -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`

5
TODO.md Normal file
View File

@ -0,0 +1,5 @@
- [x] pagination
- [x] references/links
- [x] manual sync
- fix sources where refresh token is missing (panics)

View File

@ -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
}

View File

@ -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
}

View File

@ -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")

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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(&currentUser, models.User{Username: ginCtx.MustGet("AUTH_USERNAME").(string)})
return &currentUser
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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 ""
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
*/

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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})
}

View File

@ -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
}

View File

@ -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})
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -22,4 +22,4 @@ web:
log:
file: '' #absolute or relative paths allowed, eg. web.log
level: INFO
level: DEBUG

View File

@ -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": {

View File

@ -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) },

View File

@ -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() {

View File

@ -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: {

View File

@ -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
}
}

View File

@ -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';

View File

@ -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>

View File

@ -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',

View File

@ -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}`);
}
}

View File

@ -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] || [])
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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[]

View File

@ -1,4 +1,5 @@
export class User {
user_id?: number
full_name?: string
username?: string
password?: string

View File

@ -1,4 +1,4 @@
import {Source} from '../../../lib/models/database/source';
import {Source} from '../../models/fasten/source';
export class SourceSyncMessage {
source: Source

View File

@ -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)=>{

View File

@ -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';

View File

@ -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>

View File

@ -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
});
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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);
});
}
}

View 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>

View File

@ -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();
});
});

View File

@ -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 {
}
}

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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 {
}
}

View File

@ -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>

View File

@ -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() {}
}

View File

@ -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")
}
}
}

View File

@ -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>

View File

@ -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){

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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', () => {

View File

@ -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
})
);
}
}

View File

@ -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
}
}

View File

@ -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';

View File

@ -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();
});
});

View File

@ -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)
}
}
}

View File

@ -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);

View File

@ -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}`)
}
}

View File

@ -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)
}
}

View File

@ -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");
});
});
})

View File

@ -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
})
}
}

View File

@ -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']);
});
})
})

View File

@ -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
/////////////////////////////////////////////////////////////////////////////
}

View File

@ -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"
}
]
}
}
]
}

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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);
}
}

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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);
}
}

View File

@ -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
}

View File

@ -1,4 +0,0 @@
export enum DocType {
Source = "source",
ResourceFhir = "resource_fhir"
}

View File

@ -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>
}

View File

@ -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
}
}

View File

@ -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);
});
}
}

View File

@ -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');
});
})
});

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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