begin restoring Sqlite functionality. (#11)
This commit is contained in:
parent
139b483435
commit
e360369706
|
@ -13,7 +13,6 @@ Fasten is made up of a handful of different components. Here's a summary of the
|
||||||
|
|
||||||
**Backend**
|
**Backend**
|
||||||
- Go `v1.18.3`
|
- Go `v1.18.3`
|
||||||
- CouchDB `v3.2`
|
|
||||||
|
|
||||||
**Misc**
|
**Misc**
|
||||||
- Docker `v20.10.17`
|
- Docker `v20.10.17`
|
||||||
|
@ -65,6 +64,8 @@ web:
|
||||||
src:
|
src:
|
||||||
frontend:
|
frontend:
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
database:
|
||||||
|
location: 'fasten.db'
|
||||||
log:
|
log:
|
||||||
file: '' #absolute or relative paths allowed, eg. web.log
|
file: '' #absolute or relative paths allowed, eg. web.log
|
||||||
level: INFO
|
level: INFO
|
||||||
|
@ -79,10 +80,6 @@ cd frontend
|
||||||
npm run dist -- -c sandbox
|
npm run dist -- -c sandbox
|
||||||
|
|
||||||
# In terminal #2, run the following
|
# 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 mod vendor
|
||||||
go run backend/cmd/fasten/fasten.go start --config ./config.dev.yaml --debug
|
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
|
The following URL's and credentials may be helpful as you're developing
|
||||||
|
|
||||||
- http://localhost:9090/web/dashboard - WebUI
|
- http://localhost:9090/web/dashboard - WebUI
|
||||||
- http://localhost:9090/database - CouchDB API proxy
|
|
||||||
- http://localhost:5984/_utils/ - CouchDB admin UI
|
|
||||||
|
|
||||||
### Credentials
|
### Credentials
|
||||||
- Couchdb:
|
|
||||||
- username: `admin`
|
|
||||||
- password: `mysecretpassword`
|
|
||||||
- WebUI:
|
- WebUI:
|
||||||
- username: `testuser`
|
- username: `testuser`
|
||||||
- password: `testuser`
|
- password: `testuser`
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
|
||||||
|
- [x] pagination
|
||||||
|
- [x] references/links
|
||||||
|
- [x] manual sync
|
||||||
|
- fix sources where refresh token is missing (panics)
|
|
@ -0,0 +1,60 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) {
|
||||||
|
log.Printf("ISSUER KEY: " + issuerSigningKey)
|
||||||
|
userClaims := UserRegisteredClaims{
|
||||||
|
FullName: user.FullName,
|
||||||
|
UserId: user.ID.String(),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
// In JWT, the expiry time is expressed as unix milliseconds
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "docker-fastenhealth",
|
||||||
|
Subject: user.Username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//FASTEN_JWT_ISSUER_KEY
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims)
|
||||||
|
//token.Header["kid"] = "docker"
|
||||||
|
tokenString, err := token.SignedString([]byte(issuerSigningKey))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func JwtValidateFastenToken(encryptionKey string, signedToken string) (*UserRegisteredClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(
|
||||||
|
signedToken,
|
||||||
|
&UserRegisteredClaims{},
|
||||||
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if jwt.SigningMethodHS256 != token.Method {
|
||||||
|
return nil, errors.New("Invalid signing algorithm")
|
||||||
|
}
|
||||||
|
return []byte(encryptionKey), nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*UserRegisteredClaims)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New("couldn't parse claims")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt.Unix() < time.Now().Local().Unix() {
|
||||||
|
err = errors.New("token expired")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/golang-jwt/jwt/v4"
|
||||||
|
|
||||||
|
type UserRegisteredClaims struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
UserId string `json:"user_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
|
@ -26,11 +26,7 @@ func (c *configuration) Init() error {
|
||||||
c.SetDefault("web.listen.basepath", "")
|
c.SetDefault("web.listen.basepath", "")
|
||||||
c.SetDefault("web.src.frontend.path", "/opt/fasten/web")
|
c.SetDefault("web.src.frontend.path", "/opt/fasten/web")
|
||||||
|
|
||||||
c.SetDefault("couchdb.scheme", "http")
|
c.SetDefault("database.location", "/opt/fasten/db/fasten.db") //TODO: should be /opt/fasten/fasten.db
|
||||||
c.SetDefault("couchdb.host", "localhost")
|
|
||||||
c.SetDefault("couchdb.port", "5984")
|
|
||||||
c.SetDefault("couchdb.admin.username", "admin")
|
|
||||||
c.SetDefault("couchdb.admin.password", "mysecretpassword")
|
|
||||||
|
|
||||||
c.SetDefault("jwt.issuer.key", "thisismysupersecuressessionsecretlength")
|
c.SetDefault("jwt.issuer.key", "thisismysupersecuressessionsecretlength")
|
||||||
|
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
|
||||||
"github.com/go-kivik/couchdb/v3"
|
|
||||||
_ "github.com/go-kivik/couchdb/v3" // The CouchDB driver
|
|
||||||
"github.com/go-kivik/kivik/v3"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) (DatabaseRepository, error) {
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Couchdb setup
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", appConfig.GetString("couchdb.scheme"), appConfig.GetString("couchdb.host"), appConfig.GetString("couchdb.port"))
|
|
||||||
|
|
||||||
globalLogger.Infof("Trying to connect to couchdb: %s\n", couchdbUrl)
|
|
||||||
|
|
||||||
database, err := kivik.New("couch", couchdbUrl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Failed to connect to database! - %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.Authenticate(context.Background(),
|
|
||||||
couchdb.BasicAuth(
|
|
||||||
appConfig.GetString("couchdb.admin.username"),
|
|
||||||
appConfig.GetString("couchdb.admin.password")),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Failed to authenticate to database! - %v", err)
|
|
||||||
}
|
|
||||||
globalLogger.Infof("Successfully connected to couchdb: %s\n", couchdbUrl)
|
|
||||||
|
|
||||||
deviceRepo := couchdbRepository{
|
|
||||||
appConfig: appConfig,
|
|
||||||
logger: globalLogger,
|
|
||||||
client: database,
|
|
||||||
}
|
|
||||||
return &deviceRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchdbRepository struct {
|
|
||||||
appConfig config.Interface
|
|
||||||
logger logrus.FieldLogger
|
|
||||||
|
|
||||||
client *kivik.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchDbUser struct {
|
|
||||||
ID string `json:"_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Roles []string `json:"roles"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *couchdbRepository) CreateUser(ctx context.Context, user *models.User) error {
|
|
||||||
|
|
||||||
newUser := &couchDbUser{
|
|
||||||
ID: fmt.Sprintf("%s%s", kivik.UserPrefix, user.Username),
|
|
||||||
Name: user.Username,
|
|
||||||
Type: "user",
|
|
||||||
Roles: []string{},
|
|
||||||
Password: user.Password,
|
|
||||||
|
|
||||||
FullName: user.FullName,
|
|
||||||
}
|
|
||||||
db := cr.client.DB(ctx, "_users")
|
|
||||||
_, err := db.Put(ctx, newUser.ID, newUser)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: we should create an index for this database now
|
|
||||||
//db.CreateIndex(ctx, )
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *couchdbRepository) VerifyUser(ctx context.Context, user *models.User) error {
|
|
||||||
|
|
||||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", cr.appConfig.GetString("couchdb.scheme"), cr.appConfig.GetString("couchdb.host"), cr.appConfig.GetString("couchdb.port"))
|
|
||||||
|
|
||||||
userDatabase, err := kivik.New("couch", couchdbUrl)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to connect to database! - %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = userDatabase.Authenticate(context.Background(),
|
|
||||||
couchdb.BasicAuth(user.Username, user.Password),
|
|
||||||
)
|
|
||||||
session, err := userDatabase.Session(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("SESSION INFO: %v", session)
|
|
||||||
//TODO: return session info
|
|
||||||
|
|
||||||
//lookup the user in the user database using admin creds
|
|
||||||
adminDb := cr.client.DB(ctx, "_users")
|
|
||||||
userRow := adminDb.Get(ctx, kivik.UserPrefix+session.Name)
|
|
||||||
userDoc := map[string]interface{}{}
|
|
||||||
err = userRow.ScanDoc(&userDoc)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if userFullName, hasUserFullName := userDoc["full_name"]; hasUserFullName {
|
|
||||||
user.FullName = userFullName.(string)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *couchdbRepository) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
sourcePkg "github.com/fastenhealth/fasten-sources/clients/models"
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,5 +11,24 @@ type DatabaseRepository interface {
|
||||||
Close() error
|
Close() error
|
||||||
|
|
||||||
CreateUser(context.Context, *models.User) error
|
CreateUser(context.Context, *models.User) error
|
||||||
VerifyUser(context.Context, *models.User) error
|
|
||||||
|
GetUserByEmail(context.Context, string) (*models.User, error)
|
||||||
|
GetCurrentUser(context.Context) *models.User
|
||||||
|
|
||||||
|
GetSummary(ctx context.Context) (*models.Summary, error)
|
||||||
|
|
||||||
|
GetResourceBySourceType(context.Context, string, string) (*models.ResourceFhir, error)
|
||||||
|
GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error)
|
||||||
|
ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error)
|
||||||
|
GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
|
||||||
|
//UpsertProfile(context.Context, *models.Profile) error
|
||||||
|
//UpsertOrganziation(context.Context, *models.Organization) error
|
||||||
|
|
||||||
|
CreateSource(context.Context, *models.SourceCredential) error
|
||||||
|
GetSource(context.Context, string) (*models.SourceCredential, error)
|
||||||
|
GetSourceSummary(context.Context, string) (*models.SourceSummary, error)
|
||||||
|
GetSources(context.Context) ([]models.SourceCredential, error)
|
||||||
|
|
||||||
|
//used by Client
|
||||||
|
UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// Code generated by MockGen. DO NOT EDIT.
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
// Source: interface.go
|
// SourceCredential: interface.go
|
||||||
|
|
||||||
// Package mock_database is a generated GoMock package.
|
// Package mock_database is a generated GoMock package.
|
||||||
package mock_database
|
package mock_database
|
||||||
|
|
|
@ -0,0 +1,442 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
sourceModel "github.com/fastenhealth/fasten-sources/clients/models"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger) (DatabaseRepository, error) {
|
||||||
|
//backgroundContext := context.Background()
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Gorm/SQLite setup
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
globalLogger.Infof("Trying to connect to sqlite db: %s\n", appConfig.GetString("database.location"))
|
||||||
|
|
||||||
|
// When a transaction cannot lock the database, because it is already locked by another one,
|
||||||
|
// SQLite by default throws an error: database is locked. This behavior is usually not appropriate when
|
||||||
|
// concurrent access is needed, typically when multiple processes write to the same database.
|
||||||
|
// PRAGMA busy_timeout lets you set a timeout or a handler for these events. When setting a timeout,
|
||||||
|
// SQLite will try the transaction multiple times within this timeout.
|
||||||
|
// fixes #341
|
||||||
|
// https://rsqlite.r-dbi.org/reference/sqlitesetbusyhandler
|
||||||
|
// retrying for 30000 milliseconds, 30seconds - this would be unreasonable for a distributed multi-tenant application,
|
||||||
|
// but should be fine for local usage.
|
||||||
|
pragmaStr := sqlitePragmaString(map[string]string{
|
||||||
|
"busy_timeout": "30000",
|
||||||
|
"foreign_keys": "ON",
|
||||||
|
})
|
||||||
|
database, err := gorm.Open(sqlite.Open(appConfig.GetString("database.location")+pragmaStr), &gorm.Config{
|
||||||
|
//TODO: figure out how to log database queries again.
|
||||||
|
//Logger: logger
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.ToUpper(appConfig.GetString("log.level")) == "DEBUG" {
|
||||||
|
database = database.Debug() //set debug globally
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to connect to database! - %v", err)
|
||||||
|
}
|
||||||
|
globalLogger.Infof("Successfully connected to fasten sqlite db: %s\n", appConfig.GetString("database.location"))
|
||||||
|
|
||||||
|
//TODO: automigrate for now
|
||||||
|
err = database.AutoMigrate(
|
||||||
|
&models.User{},
|
||||||
|
&models.SourceCredential{},
|
||||||
|
&models.ResourceFhir{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to automigrate! - %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create/update admin user
|
||||||
|
adminUser := models.User{}
|
||||||
|
err = database.FirstOrCreate(&adminUser, models.User{Username: "admin"}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to create admin user! - %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceRepo := sqliteRepository{
|
||||||
|
appConfig: appConfig,
|
||||||
|
logger: globalLogger,
|
||||||
|
gormClient: database,
|
||||||
|
}
|
||||||
|
return &deviceRepo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sqliteRepository struct {
|
||||||
|
appConfig config.Interface
|
||||||
|
logger logrus.FieldLogger
|
||||||
|
|
||||||
|
gormClient *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// User
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) CreateUser(ctx context.Context, user *models.User) error {
|
||||||
|
if err := user.HashPassword(user.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record := sr.gormClient.Create(user)
|
||||||
|
if record.Error != nil {
|
||||||
|
return record.Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (sr *sqliteRepository) GetUserByEmail(ctx context.Context, username string) (*models.User, error) {
|
||||||
|
var foundUser models.User
|
||||||
|
result := sr.gormClient.Where(models.User{Username: username}).First(&foundUser)
|
||||||
|
return &foundUser, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetCurrentUser(ctx context.Context) *models.User {
|
||||||
|
ginCtx := ctx.(*gin.Context)
|
||||||
|
var currentUser models.User
|
||||||
|
sr.gormClient.First(¤tUser, models.User{Username: ginCtx.MustGet("AUTH_USERNAME").(string)})
|
||||||
|
|
||||||
|
return ¤tUser
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// User
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetSummary(ctx context.Context) (*models.Summary, error) {
|
||||||
|
|
||||||
|
// we want a count of all resources for this user by type
|
||||||
|
var resourceCountResults []map[string]interface{}
|
||||||
|
|
||||||
|
//group by resource type and return counts
|
||||||
|
// SELECT source_resource_type as resource_type, COUNT(*) as count FROM resource_fhirs WHERE source_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9" GROUP BY source_resource_type;
|
||||||
|
result := sr.gormClient.WithContext(ctx).
|
||||||
|
Model(models.ResourceFhir{}).
|
||||||
|
Select("source_id, source_resource_type as resource_type, count(*) as count").
|
||||||
|
Group("source_resource_type").
|
||||||
|
Where(models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
}).
|
||||||
|
Scan(&resourceCountResults)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want a list of all sources (when they were last updated)
|
||||||
|
sources, err := sr.GetSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want the main Patient for each source
|
||||||
|
patients, err := sr.GetPatientForSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resourceCountResults == nil {
|
||||||
|
resourceCountResults = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
summary := &models.Summary{
|
||||||
|
Sources: sources,
|
||||||
|
ResourceTypeCounts: resourceCountResults,
|
||||||
|
Patients: patients,
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Resource
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) UpsertRawResource(ctx context.Context, sourceCredential sourceModel.SourceCredential, rawResource sourceModel.RawResourceFhir) (bool, error) {
|
||||||
|
|
||||||
|
source := sourceCredential.(models.SourceCredential)
|
||||||
|
|
||||||
|
wrappedResourceModel := &models.ResourceFhir{
|
||||||
|
OriginBase: models.OriginBase{
|
||||||
|
ModelBase: models.ModelBase{},
|
||||||
|
UserID: source.UserID,
|
||||||
|
SourceID: source.ID,
|
||||||
|
SourceResourceID: rawResource.SourceResourceID,
|
||||||
|
SourceResourceType: rawResource.SourceResourceType,
|
||||||
|
},
|
||||||
|
ResourceRaw: datatypes.JSON(rawResource.ResourceRaw),
|
||||||
|
}
|
||||||
|
|
||||||
|
sr.logger.Infof("insert/update (%v) %v", rawResource.SourceResourceType, rawResource.SourceResourceID)
|
||||||
|
|
||||||
|
createResult := sr.gormClient.WithContext(ctx).Where(models.OriginBase{
|
||||||
|
SourceID: wrappedResourceModel.GetSourceID(),
|
||||||
|
SourceResourceID: wrappedResourceModel.GetSourceResourceID(),
|
||||||
|
SourceResourceType: wrappedResourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||||
|
}).FirstOrCreate(wrappedResourceModel)
|
||||||
|
|
||||||
|
if createResult.Error != nil {
|
||||||
|
return false, createResult.Error
|
||||||
|
} else if createResult.RowsAffected == 0 {
|
||||||
|
//at this point, wrappedResourceModel contains the data found in the database.
|
||||||
|
// check if the database resource matches the new resource.
|
||||||
|
if wrappedResourceModel.ResourceRaw.String() != string(rawResource.ResourceRaw) {
|
||||||
|
updateResult := createResult.Updates(wrappedResourceModel)
|
||||||
|
return updateResult.RowsAffected > 0, updateResult.Error
|
||||||
|
} else {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
//resource was created
|
||||||
|
return createResult.RowsAffected > 0, createResult.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
//return results.RowsAffected > 0, results.Error
|
||||||
|
|
||||||
|
//if sr.gormClient.Debug().WithContext(ctx).
|
||||||
|
// Where(models.OriginBase{
|
||||||
|
// SourceID: wrappedResourceModel.GetSourceID(),
|
||||||
|
// SourceResourceID: wrappedResourceModel.GetSourceResourceID(),
|
||||||
|
// SourceResourceType: wrappedResourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||||
|
// }).Updates(wrappedResourceModel).RowsAffected == 0 {
|
||||||
|
// sr.logger.Infof("resource does not exist, creating: %s %s %s", wrappedResourceModel.GetSourceID(), wrappedResourceModel.GetSourceResourceID(), wrappedResourceModel.GetSourceResourceType())
|
||||||
|
// return sr.gormClient.Debug().Create(wrappedResourceModel).Error
|
||||||
|
//}
|
||||||
|
//return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) UpsertResource(ctx context.Context, resourceModel *models.ResourceFhir) error {
|
||||||
|
sr.logger.Infof("insert/update (%T) %v", resourceModel, resourceModel)
|
||||||
|
|
||||||
|
if sr.gormClient.WithContext(ctx).
|
||||||
|
Where(models.OriginBase{
|
||||||
|
SourceID: resourceModel.GetSourceID(),
|
||||||
|
SourceResourceID: resourceModel.GetSourceResourceID(),
|
||||||
|
SourceResourceType: resourceModel.GetSourceResourceType(), //TODO: and UpdatedAt > old UpdatedAt
|
||||||
|
}).Updates(resourceModel).RowsAffected == 0 {
|
||||||
|
sr.logger.Infof("resource does not exist, creating: %s %s %s", resourceModel.GetSourceID(), resourceModel.GetSourceResourceID(), resourceModel.GetSourceResourceType())
|
||||||
|
return sr.gormClient.Create(resourceModel).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) ListResources(ctx context.Context, queryOptions models.ListResourceQueryOptions) ([]models.ResourceFhir, error) {
|
||||||
|
|
||||||
|
queryParam := models.ResourceFhir{
|
||||||
|
OriginBase: models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queryOptions.SourceResourceType) > 0 {
|
||||||
|
queryParam.OriginBase.SourceResourceType = queryOptions.SourceResourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queryOptions.SourceID) > 0 {
|
||||||
|
sourceUUID, err := uuid.Parse(queryOptions.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParam.OriginBase.SourceID = sourceUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestJson, _ := json.MarshalIndent(queryParam, "", " ")
|
||||||
|
sr.logger.Infof("THE QUERY OBJECT===========> %v", string(manifestJson))
|
||||||
|
|
||||||
|
var wrappedResourceModels []models.ResourceFhir
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(queryParam).
|
||||||
|
Find(&wrappedResourceModels)
|
||||||
|
|
||||||
|
return wrappedResourceModels, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetResourceBySourceType(ctx context.Context, sourceResourceType string, sourceResourceId string) (*models.ResourceFhir, error) {
|
||||||
|
queryParam := models.ResourceFhir{
|
||||||
|
OriginBase: models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
SourceResourceType: sourceResourceType,
|
||||||
|
SourceResourceID: sourceResourceId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedResourceModel models.ResourceFhir
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(queryParam).
|
||||||
|
First(&wrappedResourceModel)
|
||||||
|
|
||||||
|
return &wrappedResourceModel, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetResourceBySourceId(ctx context.Context, sourceId string, sourceResourceId string) (*models.ResourceFhir, error) {
|
||||||
|
sourceIdUUID, err := uuid.Parse(sourceId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParam := models.ResourceFhir{
|
||||||
|
OriginBase: models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
SourceID: sourceIdUUID,
|
||||||
|
SourceResourceID: sourceResourceId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedResourceModel models.ResourceFhir
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(queryParam).
|
||||||
|
First(&wrappedResourceModel)
|
||||||
|
|
||||||
|
return &wrappedResourceModel, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the patient for each source (for the current user)
|
||||||
|
func (sr *sqliteRepository) GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error) {
|
||||||
|
|
||||||
|
//SELECT * FROM resource_fhirs WHERE user_id = "" and source_resource_type = "Patient" GROUP BY source_id
|
||||||
|
|
||||||
|
//var sourceCred models.SourceCredential
|
||||||
|
//results := sr.gormClient.WithContext(ctx).
|
||||||
|
// Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID, ModelBase: models.ModelBase{ID: sourceUUID}}).
|
||||||
|
// First(&sourceCred)
|
||||||
|
|
||||||
|
var wrappedResourceModels []models.ResourceFhir
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Model(models.ResourceFhir{}).
|
||||||
|
Group("source_id").
|
||||||
|
Where(models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
SourceResourceType: "Patient",
|
||||||
|
}).
|
||||||
|
Find(&wrappedResourceModels)
|
||||||
|
|
||||||
|
return wrappedResourceModels, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// SourceCredential
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) CreateSource(ctx context.Context, sourceCreds *models.SourceCredential) error {
|
||||||
|
sourceCreds.UserID = sr.GetCurrentUser(ctx).ID
|
||||||
|
|
||||||
|
//Assign will **always** update the source credential in the DB with data passed into this function.
|
||||||
|
return sr.gormClient.WithContext(ctx).
|
||||||
|
Where(models.SourceCredential{
|
||||||
|
UserID: sourceCreds.UserID,
|
||||||
|
SourceType: sourceCreds.SourceType,
|
||||||
|
Patient: sourceCreds.Patient}).
|
||||||
|
Assign(*sourceCreds).FirstOrCreate(sourceCreds).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetSource(ctx context.Context, sourceId string) (*models.SourceCredential, error) {
|
||||||
|
sourceUUID, err := uuid.Parse(sourceId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceCred models.SourceCredential
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID, ModelBase: models.ModelBase{ID: sourceUUID}}).
|
||||||
|
First(&sourceCred)
|
||||||
|
|
||||||
|
return &sourceCred, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetSourceSummary(ctx context.Context, sourceId string) (*models.SourceSummary, error) {
|
||||||
|
sourceUUID, err := uuid.Parse(sourceId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSummary := &models.SourceSummary{}
|
||||||
|
|
||||||
|
source, err := sr.GetSource(ctx, sourceId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceSummary.Source = source
|
||||||
|
|
||||||
|
//group by resource type and return counts
|
||||||
|
// SELECT source_resource_type as resource_type, COUNT(*) as count FROM resource_fhirs WHERE source_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9" GROUP BY source_resource_type;
|
||||||
|
|
||||||
|
var resourceTypeCounts []map[string]interface{}
|
||||||
|
|
||||||
|
result := sr.gormClient.WithContext(ctx).
|
||||||
|
Model(models.ResourceFhir{}).
|
||||||
|
Select("source_id, source_resource_type as resource_type, count(*) as count").
|
||||||
|
Group("source_resource_type").
|
||||||
|
Where(models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
SourceID: sourceUUID,
|
||||||
|
}).
|
||||||
|
Scan(&resourceTypeCounts)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSummary.ResourceTypeCounts = resourceTypeCounts
|
||||||
|
|
||||||
|
//set patient
|
||||||
|
var wrappedPatientResourceModel models.ResourceFhir
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(models.OriginBase{
|
||||||
|
UserID: sr.GetCurrentUser(ctx).ID,
|
||||||
|
SourceResourceType: "Patient",
|
||||||
|
SourceID: sourceUUID,
|
||||||
|
}).
|
||||||
|
First(&wrappedPatientResourceModel)
|
||||||
|
|
||||||
|
if results.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
sourceSummary.Patient = &wrappedPatientResourceModel
|
||||||
|
|
||||||
|
return sourceSummary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sr *sqliteRepository) GetSources(ctx context.Context) ([]models.SourceCredential, error) {
|
||||||
|
|
||||||
|
var sourceCreds []models.SourceCredential
|
||||||
|
results := sr.gormClient.WithContext(ctx).
|
||||||
|
Where(models.SourceCredential{UserID: sr.GetCurrentUser(ctx).ID}).
|
||||||
|
Find(&sourceCreds)
|
||||||
|
|
||||||
|
return sourceCreds, results.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utilities
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func sqlitePragmaString(pragmas map[string]string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for key, val := range pragmas {
|
||||||
|
q.Add("_pragma", key+"="+val)
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStr := q.Encode()
|
||||||
|
if len(queryStr) > 0 {
|
||||||
|
return "?" + queryStr
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelBase struct {
|
||||||
|
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//https://medium.com/@the.hasham.ali/how-to-use-uuid-key-type-with-gorm-cc00d4ec7100
|
||||||
|
|
||||||
|
func (base *ModelBase) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
base.ID = uuid.New()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OriginBase struct {
|
||||||
|
ModelBase
|
||||||
|
User *User `json:"user,omitempty" gorm:"-"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
|
||||||
|
Source *SourceCredential `json:"source,omitempty" gorm:"-"`
|
||||||
|
SourceID uuid.UUID `json:"source_id" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||||
|
|
||||||
|
SourceResourceType string `json:"source_resource_type" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||||
|
SourceResourceID string `json:"source_resource_id" gorm:"not null;index:,unique,composite:source_resource_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o OriginBase) GetSourceID() uuid.UUID {
|
||||||
|
return o.SourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o OriginBase) GetSourceResourceType() string {
|
||||||
|
return o.SourceResourceType
|
||||||
|
}
|
||||||
|
func (o OriginBase) GetSourceResourceID() string {
|
||||||
|
return o.SourceResourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
type OriginBaser interface {
|
||||||
|
GetSourceID() uuid.UUID
|
||||||
|
GetSourceResourceType() string
|
||||||
|
GetSourceResourceID() string
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceFhir struct {
|
||||||
|
OriginBase
|
||||||
|
|
||||||
|
//embedded data
|
||||||
|
ResourceRaw datatypes.JSON `json:"resource_raw" gorm:"resource_raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResourceQueryOptions struct {
|
||||||
|
SourceID string
|
||||||
|
SourceResourceType string
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fastenhealth/fasten-sources/pkg"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceCredential Data/Medical Provider Credentials
|
||||||
|
// similar to LighthouseSourceDefinition from fasten-source
|
||||||
|
type SourceCredential struct {
|
||||||
|
ModelBase
|
||||||
|
User User `json:"user,omitempty"`
|
||||||
|
UserID uuid.UUID `json:"user_id" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||||
|
SourceType pkg.SourceType `json:"source_type" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||||
|
Patient string `json:"patient" gorm:"uniqueIndex:idx_user_source_patient"`
|
||||||
|
|
||||||
|
//oauth endpoints
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||||
|
|
||||||
|
Scopes []string `json:"scopes_supported" gorm:"type:text;serializer:json"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
GrantTypesSupported []string `json:"grant_types_supported" gorm:"type:text;serializer:json"`
|
||||||
|
ResponseType []string `json:"response_types_supported" gorm:"type:text;serializer:json"`
|
||||||
|
ResponseModesSupported []string `json:"response_modes_supported" gorm:"type:text;serializer:json"`
|
||||||
|
Audience string `json:"aud"` //optional - required for some providers
|
||||||
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported" gorm:"type:text;serializer:json"`
|
||||||
|
|
||||||
|
//Fasten custom configuration
|
||||||
|
UserInfoEndpoint string `json:"userinfo_endpoint"` //optional - supported by some providers, not others.
|
||||||
|
ApiEndpointBaseUrl string `json:"api_endpoint_base_url"` //api endpoint we'll communicate with after authentication
|
||||||
|
ClientId string `json:"client_id"`
|
||||||
|
RedirectUri string `json:"redirect_uri"` //lighthouse url the provider will redirect to (registered with App)
|
||||||
|
|
||||||
|
Confidential bool `json:"confidential"` //if enabled, requires client_secret to authenticate with provider (PKCE)
|
||||||
|
CORSRelayRequired bool `json:"cors_relay_required"` //if true, requires CORS proxy/relay, as provider does not return proper response to CORS preflight
|
||||||
|
//SecretKeyPrefix string `json:"-"` //the secret key prefix to use, if empty (default) will use the sourceType value
|
||||||
|
|
||||||
|
// auth/credential data
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IdToken string `json:"id_token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
CodeChallenge string `json:"code_challenge"`
|
||||||
|
CodeVerifier string `json:"code_verifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetSourceType() pkg.SourceType {
|
||||||
|
return s.SourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetClientId() string {
|
||||||
|
return s.ClientId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetPatientId() string {
|
||||||
|
return s.Patient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetOauthAuthorizationEndpoint() string {
|
||||||
|
return s.AuthorizationEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetOauthTokenEndpoint() string {
|
||||||
|
return s.TokenEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetApiEndpointBaseUrl() string {
|
||||||
|
return s.ApiEndpointBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetRefreshToken() string {
|
||||||
|
return s.RefreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetAccessToken() string {
|
||||||
|
return s.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) GetExpiresAt() int64 {
|
||||||
|
return s.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SourceCredential) RefreshTokens(accessToken string, refreshToken string, expiresAt int64) {
|
||||||
|
if accessToken != s.AccessToken {
|
||||||
|
// update the "source" credential with new data (which will need to be sent
|
||||||
|
s.AccessToken = accessToken
|
||||||
|
s.ExpiresAt = expiresAt
|
||||||
|
// Don't overwrite `RefreshToken` with an empty value
|
||||||
|
// if this was a token refreshing request.
|
||||||
|
if refreshToken != "" {
|
||||||
|
s.RefreshToken = refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
serverUrl: connectData.message.api_endpoint_base_url,
|
||||||
|
clientId: connectData.message.client_id,
|
||||||
|
redirectUri: connectData.message.redirect_uri,
|
||||||
|
tokenUri: `${connectData.message.oauth_endpoint_base_url}/token`,
|
||||||
|
scope: connectData.message.scopes.join(' '),
|
||||||
|
tokenResponse: payload,
|
||||||
|
expiresAt: getAccessTokenExpiration(payload, new BrowserAdapter()),
|
||||||
|
codeChallenge: codeChallenge,
|
||||||
|
codeVerifier: codeVerifier
|
||||||
|
|
||||||
|
*/
|
|
@ -0,0 +1,7 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type SourceSummary struct {
|
||||||
|
Source *SourceCredential `json:"source,omitempty"`
|
||||||
|
ResourceTypeCounts []map[string]interface{} `json:"resource_type_counts,omitempty"`
|
||||||
|
Patient *ResourceFhir `json:"patient"`
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Summary struct {
|
||||||
|
Sources []SourceCredential `json:"sources"`
|
||||||
|
Patients []ResourceFhir `json:"patients"`
|
||||||
|
ResourceTypeCounts []map[string]interface{} `json:"resource_type_counts"`
|
||||||
|
}
|
|
@ -1,7 +1,26 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
ModelBase
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username" gorm:"unique"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (user *User) HashPassword(password string) error {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Password = string(bytes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (user *User) CheckPassword(providedPassword string) error {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(providedPassword))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
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/config"
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
jwt "github.com/golang-jwt/jwt/v4"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthSignup(c *gin.Context) {
|
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.
|
//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})
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
return
|
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 {
|
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
|
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.
|
//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})
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken})
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisteredClaims struct {
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func jwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) {
|
|
||||||
log.Printf("ISSUER KEY: " + issuerSigningKey)
|
|
||||||
userClaims := UserRegisteredClaims{
|
|
||||||
FullName: user.FullName,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
// In JWT, the expiry time is expressed as unix milliseconds
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: "docker-fastenhealth",
|
|
||||||
Subject: user.Username,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
//FASTEN_JWT_ISSUER_KEY
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims)
|
|
||||||
//token.Header["kid"] = "docker"
|
|
||||||
tokenString, err := token.SignedString([]byte(issuerSigningKey))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return tokenString, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CouchDBProxy(c *gin.Context) {
|
|
||||||
appConfig := c.MustGet("CONFIG").(config.Interface)
|
|
||||||
|
|
||||||
couchdbUrl := fmt.Sprintf("%s://%s:%s", appConfig.GetString("couchdb.scheme"), appConfig.GetString("couchdb.host"), appConfig.GetString("couchdb.port"))
|
|
||||||
remote, err := url.Parse(couchdbUrl)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(remote)
|
|
||||||
//Define the director func
|
|
||||||
//This is a good place to log, for example
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.Header = c.Request.Header
|
|
||||||
req.Header.Add("X-Forwarded-Host", req.Host)
|
|
||||||
req.Header.Add("X-Origin-Host", remote.Host)
|
|
||||||
req.Host = remote.Host
|
|
||||||
req.URL.Scheme = remote.Scheme
|
|
||||||
req.URL.Host = remote.Host
|
|
||||||
log.Printf(c.Param("proxyPath"))
|
|
||||||
req.URL.Path = strings.TrimPrefix(c.Param("proxyPath"), "/database")
|
|
||||||
|
|
||||||
//todo: throw an error if not a user DB.
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
|
||||||
}
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListResourceFhir(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
listResourceQueryOptions := models.ListResourceQueryOptions{}
|
||||||
|
if len(c.Query("sourceResourceType")) > 0 {
|
||||||
|
listResourceQueryOptions.SourceResourceType = c.Query("sourceResourceType")
|
||||||
|
}
|
||||||
|
if len(c.Query("sourceID")) > 0 {
|
||||||
|
listResourceQueryOptions.SourceID = c.Query("sourceID")
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedResourceModels, err := databaseRepo.ListResources(c, listResourceQueryOptions)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving resources", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": wrappedResourceModels})
|
||||||
|
}
|
||||||
|
|
||||||
|
//this endpoint retrieves a specific resource by its ID
|
||||||
|
func GetResourceFhir(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
resourceId := strings.Trim(c.Param("resourceId"), "/")
|
||||||
|
sourceId := strings.Trim(c.Param("sourceId"), "/")
|
||||||
|
wrappedResourceModel, err := databaseRepo.GetResourceBySourceId(c, sourceId, resourceId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving resource", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": wrappedResourceModel})
|
||||||
|
}
|
|
@ -0,0 +1,263 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/fastenhealth/fasten-sources/clients/factory"
|
||||||
|
sourceModels "github.com/fastenhealth/fasten-sources/clients/models"
|
||||||
|
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateSource(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
sourceCred := models.SourceCredential{}
|
||||||
|
if err := c.ShouldBindJSON(&sourceCred); err != nil {
|
||||||
|
logger.Errorln("An error occurred while parsing posted source credential", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Parsed Create SourceCredential Credentials Payload: %v", sourceCred)
|
||||||
|
|
||||||
|
err := databaseRepo.CreateSource(c, &sourceCred)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while storing source credential", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// after creating the source, we should do a bulk import
|
||||||
|
summary, err := syncSourceResources(c, logger, databaseRepo, sourceCred)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "source": sourceCred, "data": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SourceSync(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
logger.Infof("Get SourceCredential Credentials: %v", c.Param("sourceId"))
|
||||||
|
|
||||||
|
sourceCred, err := databaseRepo.GetSource(c, c.Param("sourceId"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving source credential", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// after creating the source, we should do a bulk import
|
||||||
|
summary, err := syncSourceResources(c, logger, databaseRepo, *sourceCred)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "source": sourceCred, "data": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateManualSource(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
// single file
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not extract file from form"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Uploaded filename: %s", file.Filename)
|
||||||
|
|
||||||
|
// create a temporary file to store this uploaded file
|
||||||
|
bundleFile, err := ioutil.TempFile("", file.Filename)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not create temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the file to specific bundleFile.
|
||||||
|
err = c.SaveUploadedFile(file, bundleFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not save temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We cannot save the "SourceCredential" object yet, as we do not know the patientID
|
||||||
|
|
||||||
|
// create a "manual" client, which we can use to parse the
|
||||||
|
manualSourceCredential := models.SourceCredential{
|
||||||
|
SourceType: sourcePkg.SourceTypeManual,
|
||||||
|
}
|
||||||
|
tempSourceClient, _, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourcePkg.SourceTypeManual, c, logger, manualSourceCredential)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while initializing hub client using manual source without credentials", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patientId, bundleType, err := tempSourceClient.ExtractPatientId(bundleFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while extracting patient id", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manualSourceCredential.Patient = patientId
|
||||||
|
|
||||||
|
//store the manualSourceCredential
|
||||||
|
err = databaseRepo.CreateSource(c, &manualSourceCredential)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while creating manual source", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualSourceClient, _, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourcePkg.SourceTypeManual, c, logger, manualSourceCredential)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while initializing hub client using manual source with credential", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := manualSourceClient.SyncAllBundle(databaseRepo, bundleFile, bundleType)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while processing bundle", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSource(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
sourceCred, err := databaseRepo.GetSource(c, c.Param("sourceId"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving source credential", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCred})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSourceSummary(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
sourceSummary, err := databaseRepo.GetSourceSummary(c, c.Param("sourceId"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving source summary", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceSummary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListSource(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
sourceCreds, err := databaseRepo.GetSources(c)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while listing source credentials", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCreds})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RawRequestSource(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
//!!!!!!INSECURE!!!!!!S
|
||||||
|
//We're setting the username to a user provided value, this is insecure, but required for calling databaseRepo fns
|
||||||
|
c.Set("AUTH_USERNAME", c.Param("username"))
|
||||||
|
|
||||||
|
foundSource, err := databaseRepo.GetSource(c, c.Param("sourceId"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("An error occurred while finding source credential: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundSource == nil {
|
||||||
|
logger.Errorf("Did not source credentials for %s", c.Param("sourceType"))
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), foundSource.SourceType, c, logger, foundSource)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Could not initialize source client %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//TODO: if source has been updated, we should save the access/refresh token.
|
||||||
|
if updatedSource != nil {
|
||||||
|
logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource)
|
||||||
|
// err := databaseRepo.CreateSource(c, updatedSource)
|
||||||
|
// if err != nil {
|
||||||
|
// logger.Errorf("An error occurred while updating source credential %v", err)
|
||||||
|
// c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
|
||||||
|
parsedUrl, err := url.Parse(strings.TrimSuffix(c.Param("path"), "/"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error parsing request, %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//make sure we include all query string parameters with the raw request.
|
||||||
|
parsedUrl.RawQuery = c.Request.URL.Query().Encode()
|
||||||
|
|
||||||
|
err = client.GetRequest(parsedUrl.String(), &resp)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error making raw request, %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error(), "data": resp})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
////// private functions
|
||||||
|
func syncSourceResources(c *gin.Context, logger *logrus.Entry, databaseRepo database.DatabaseRepository, sourceCred models.SourceCredential) (sourceModels.UpsertSummary, error) {
|
||||||
|
// after creating the source, we should do a bulk import
|
||||||
|
sourceClient, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourceCred.SourceType, c, logger, sourceCred)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while initializing hub client using source credential", err)
|
||||||
|
return sourceModels.UpsertSummary{}, err
|
||||||
|
}
|
||||||
|
//TODO: update source
|
||||||
|
if updatedSource != nil {
|
||||||
|
logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource)
|
||||||
|
//err := databaseRepo.CreateSource(c, updatedSource)
|
||||||
|
//if err != nil {
|
||||||
|
// logger.Errorln("An error occurred while updating source credential", err)
|
||||||
|
// return err
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := sourceClient.SyncAll(databaseRepo)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while bulk import of resources from source", err)
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetSummary(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
|
||||||
|
|
||||||
|
summary, err := databaseRepo.GetSummary(c)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving summary", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/auth"
|
||||||
|
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequireAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
authHeaderParts := strings.Split(authHeader, " ")
|
||||||
|
|
||||||
|
if len(authHeaderParts) != 2 {
|
||||||
|
log.Println("Authentication header is invalid: " + authHeader)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "request does not contain a valid token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := authHeaderParts[1]
|
||||||
|
|
||||||
|
if tokenString == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "request does not contain an access token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claim, err := auth.JwtValidateFastenToken(appConfig.GetString("jwt.issuer.key"), tokenString)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//todo, is this shared between all sessions??
|
||||||
|
c.Set("AUTH_TOKEN", tokenString)
|
||||||
|
c.Set("AUTH_USERNAME", claim.Subject)
|
||||||
|
c.Set("AUTH_USERID", claim.UserId)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,10 +42,32 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
|
||||||
|
|
||||||
api.POST("/auth/signup", handler.AuthSignup)
|
api.POST("/auth/signup", handler.AuthSignup)
|
||||||
api.POST("/auth/signin", handler.AuthSignin)
|
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)
|
secure := api.Group("/secure").Use(middleware.RequireAuth())
|
||||||
r.GET("/cors/*proxyPath", handler.CORSProxy)
|
{
|
||||||
r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
|
secure.GET("/summary", handler.GetSummary)
|
||||||
|
|
||||||
|
secure.POST("/source", handler.CreateSource)
|
||||||
|
secure.POST("/source/manual", handler.CreateManualSource)
|
||||||
|
secure.GET("/source", handler.ListSource)
|
||||||
|
secure.GET("/source/:sourceId", handler.GetSource)
|
||||||
|
secure.POST("/source/:sourceId/sync", handler.SourceSync)
|
||||||
|
secure.GET("/source/:sourceId/summary", handler.GetSourceSummary)
|
||||||
|
secure.GET("/resource/fhir", handler.ListResourceFhir) //
|
||||||
|
secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ae.Config.GetString("log.level") == "DEBUG" {
|
||||||
|
//in debug mode, this endpoint lets us request data directly from the source api
|
||||||
|
ae.Logger.Warningf("***INSECURE*** ***INSECURE*** DEBUG mode enables developer functionality, including unauthenticated raw api requests")
|
||||||
|
|
||||||
|
//http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992
|
||||||
|
api.GET("/raw/:username/:sourceId/*path", handler.RawRequestSource)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,4 +22,4 @@ web:
|
||||||
|
|
||||||
log:
|
log:
|
||||||
file: '' #absolute or relative paths allowed, eg. web.log
|
file: '' #absolute or relative paths allowed, eg. web.log
|
||||||
level: INFO
|
level: DEBUG
|
||||||
|
|
|
@ -34,8 +34,7 @@
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"node_modules/@panva/oauth4webapi/build/index.js"
|
"node_modules/@panva/oauth4webapi/build/index.js"
|
||||||
],
|
]
|
||||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"prod": {
|
"prod": {
|
||||||
|
@ -161,8 +160,7 @@
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": []
|
||||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|
|
@ -8,9 +8,9 @@ import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.c
|
||||||
import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component';
|
import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component';
|
||||||
import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component';
|
import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component';
|
||||||
import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard';
|
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 {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 = [
|
const routes: Routes = [
|
||||||
|
|
||||||
|
@ -20,14 +20,15 @@ const routes: Routes = [
|
||||||
{ path: 'auth/signup/callback/:idp_type', component: AuthSignupComponent },
|
{ path: 'auth/signup/callback/:idp_type', component: AuthSignupComponent },
|
||||||
|
|
||||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
{ path: 'dashboard', component: DashboardComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||||
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||||
{ path: 'resource/:resource_id', component: ResourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
{ path: 'source/:source_id/resource/:resource_id', component: ResourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||||
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||||
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
{ 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: '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) },
|
// { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { Component, OnInit } from '@angular/core';
|
||||||
import {NavigationEnd, Router} from '@angular/router';
|
import {NavigationEnd, Router} from '@angular/router';
|
||||||
import {fromWorker} from 'observable-webworker';
|
import {fromWorker} from 'observable-webworker';
|
||||||
import {Observable, of} from 'rxjs';
|
import {Observable, of} from 'rxjs';
|
||||||
import {QueueService} from './workers/queue.service';
|
|
||||||
import {ToastService} from './services/toast.service';
|
import {ToastService} from './services/toast.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -17,7 +16,7 @@ export class AppComponent implements OnInit {
|
||||||
showHeader:boolean = false;
|
showHeader:boolean = false;
|
||||||
showFooter:boolean = true;
|
showFooter:boolean = true;
|
||||||
|
|
||||||
constructor(private router: Router, private queueService: QueueService, private toastService: ToastService) {}
|
constructor(private router: Router, private toastService: ToastService) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|
||||||
|
|
|
@ -19,15 +19,15 @@ import { AuthSigninComponent } from './pages/auth-signin/auth-signin.component';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgxDropzoneModule } from 'ngx-dropzone';
|
import { NgxDropzoneModule } from 'ngx-dropzone';
|
||||||
import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard';
|
import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard';
|
||||||
import { EncryptionEnabledAuthGuard } from './auth-guards/encryption-enabled.auth-guard';
|
import {FastenApiService} from './services/fasten-api.service';
|
||||||
import {FastenDbService} from './services/fasten-db.service';
|
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
|
import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
|
||||||
import { HighlightModule, HIGHLIGHT_OPTIONS } from 'ngx-highlightjs';
|
import { HighlightModule, HIGHLIGHT_OPTIONS } from 'ngx-highlightjs';
|
||||||
import {AuthInterceptorService} from './services/auth-interceptor.service';
|
import {AuthInterceptorService} from './services/auth-interceptor.service';
|
||||||
import { MomentModule } from 'ngx-moment';
|
import { MomentModule } from 'ngx-moment';
|
||||||
import { EncryptionManagerComponent } from './pages/encryption-manager/encryption-manager.component';
|
|
||||||
import {AuthService} from './services/auth.service';
|
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({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -40,7 +40,8 @@ import {AuthService} from './services/auth.service';
|
||||||
AuthSignupComponent,
|
AuthSignupComponent,
|
||||||
AuthSigninComponent,
|
AuthSigninComponent,
|
||||||
SourceDetailComponent,
|
SourceDetailComponent,
|
||||||
EncryptionManagerComponent,
|
PatientProfileComponent,
|
||||||
|
MedicalHistoryComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
@ -63,7 +64,6 @@ import {AuthService} from './services/auth.service';
|
||||||
deps: [AuthService, Router]
|
deps: [AuthService, Router]
|
||||||
},
|
},
|
||||||
IsAuthenticatedAuthGuard,
|
IsAuthenticatedAuthGuard,
|
||||||
EncryptionEnabledAuthGuard,
|
|
||||||
{
|
{
|
||||||
provide: HIGHLIGHT_OPTIONS,
|
provide: HIGHLIGHT_OPTIONS,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import {CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router} from '@angular/router';
|
|
||||||
import {FastenDbService} from '../services/fasten-db.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EncryptionEnabledAuthGuard implements CanActivate {
|
|
||||||
constructor(private fastenDbService: FastenDbService, private router: Router) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise <boolean> {
|
|
||||||
//check if the user has encryption data stored in this browser already
|
|
||||||
if (!await this.fastenDbService.isCryptConfigAvailable()) {
|
|
||||||
return await this.router.navigate(['/account/security/manager']);
|
|
||||||
}
|
|
||||||
// continue as normal
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {AuthService} from '../../services/auth.service';
|
import {AuthService} from '../../services/auth.service';
|
||||||
import {UserRegisteredClaims} from '../../models/fasten/user-registered-claims';
|
import {UserRegisteredClaims} from '../../models/fasten/user-registered-claims';
|
||||||
|
|
|
@ -9,6 +9,6 @@
|
||||||
|
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
<li class="list-group-item" *ngFor="let resource of resourceList">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
|
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 {ResourceListComponentInterface} from '../list-generic-resource/list-generic-resource.component';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {Base64} from '../../../lib/utils/base64';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-list-fallback-resource',
|
selector: 'app-list-fallback-resource',
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import {ChangeDetectorRef, Component, Input, OnInit, ViewChild} from '@angular/core';
|
import {ChangeDetectorRef, Component, Input, OnInit, ViewChild} from '@angular/core';
|
||||||
import {DatatableComponent, ColumnMode, SelectionType} from '@swimlane/ngx-datatable';
|
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 {FORMATTERS, getPath, obsValue, attributeXTime} from './utils';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {Base64} from '../../../lib/utils/base64';
|
|
||||||
|
|
||||||
//all Resource list components must implement this Interface
|
//all Resource list components must implement this Interface
|
||||||
export interface ResourceListComponentInterface {
|
export interface ResourceListComponentInterface {
|
||||||
|
@ -61,7 +60,6 @@ export class ListGenericResourceComponent implements OnInit, ResourceListCompone
|
||||||
|
|
||||||
this.rows = this.resourceList.map((resource) => {
|
this.rows = this.resourceList.map((resource) => {
|
||||||
let row = {
|
let row = {
|
||||||
_id: resource._id,
|
|
||||||
source_id: resource.source_id,
|
source_id: resource.source_id,
|
||||||
source_resource_type: resource.source_resource_type,
|
source_resource_type: resource.source_resource_type,
|
||||||
source_resource_id: resource.source_resource_id
|
source_resource_id: resource.source_resource_id
|
||||||
|
@ -87,7 +85,8 @@ export class ListGenericResourceComponent implements OnInit, ResourceListCompone
|
||||||
*/
|
*/
|
||||||
onSelect({ selected }) {
|
onSelect({ selected }) {
|
||||||
console.log('Select Event', selected);
|
console.log('Select Event', selected);
|
||||||
this.router.navigateByUrl(`/resource/${Base64.Encode(selected[0]._id)}`);
|
this.router.navigateByUrl(`/source/${selected[0].source_id}/resource/${selected[0].source_resource_id}`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges, Type, ViewChild} from '@angular/core';
|
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges, Type, ViewChild} from '@angular/core';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
import {FastenApiService} from '../../services/fasten-api.service';
|
||||||
import {Source} from '../../../lib/models/database/source';
|
import {Source} from '../../models/fasten/source';
|
||||||
import {Observable, of} from 'rxjs';
|
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 {ListAdverseEventComponent} from '../list-generic-resource/list-adverse-event.component';
|
||||||
import {ListCommunicationComponent} from '../list-generic-resource/list-communication.component';
|
import {ListCommunicationComponent} from '../list-generic-resource/list-communication.component';
|
||||||
import {ListConditionComponent} from '../list-generic-resource/list-condition.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;
|
@ViewChild(ResourceListOutletDirective, {static: true}) resourceListOutlet!: ResourceListOutletDirective;
|
||||||
|
|
||||||
|
|
||||||
constructor(private fastenDb: FastenDbService) { }
|
constructor(private fastenApi: FastenApiService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadComponent()
|
this.loadComponent()
|
||||||
|
@ -63,7 +63,7 @@ export class ResourceListComponent implements OnInit, OnChanges {
|
||||||
const viewContainerRef = this.resourceListOutlet.viewContainerRef;
|
const viewContainerRef = this.resourceListOutlet.viewContainerRef;
|
||||||
viewContainerRef.clear();
|
viewContainerRef.clear();
|
||||||
|
|
||||||
this.getResources().then((resourceList) => {
|
this.getResources().subscribe((resourceList) => {
|
||||||
let componentType = this.typeLookup(this.resourceListType)
|
let componentType = this.typeLookup(this.resourceListType)
|
||||||
if(componentType != null){
|
if(componentType != null){
|
||||||
console.log("Attempting to create component", this.resourceListType, componentType)
|
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]){
|
if(this.resourceListType && !this.resourceListCache[this.resourceListType]){
|
||||||
// this resource type list has not been downloaded yet, do so now
|
// this resource type list has not been downloaded yet, do so now
|
||||||
return this.fastenDb.GetResourcesForSource(this.source._id, this.resourceListType)
|
return this.fastenApi.getResources(this.resourceListType, this.source.id)
|
||||||
.then((paginatedResponse) => {
|
.pipe(map((resourceList: ResourceFhir[]) => {
|
||||||
let resourceList = paginatedResponse.rows as ResourceFhir[]
|
|
||||||
//cache this response so we can skip the request next time
|
//cache this response so we can skip the request next time
|
||||||
this.resourceListCache[this.resourceListType] = resourceList
|
this.resourceListCache[this.resourceListType] = resourceList
|
||||||
return resourceList
|
return resourceList
|
||||||
})
|
}))
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve(this.resourceListCache[this.resourceListType] || [])
|
return of(this.resourceListCache[this.resourceListType] || [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
export class ResourceFhir {
|
||||||
|
user_id?: string
|
||||||
|
source_id: string = ""
|
||||||
|
source_resource_type: string = ""
|
||||||
|
source_resource_id: string = ""
|
||||||
|
|
||||||
|
fhir_version: string = ""
|
||||||
|
resource_raw: IResourceRaw
|
||||||
|
|
||||||
|
constructor(object?: any) {
|
||||||
|
return Object.assign(this, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//This is the "raw" Fhir resource
|
||||||
|
export interface IResourceRaw {
|
||||||
|
resourceType: string
|
||||||
|
id?: string
|
||||||
|
meta?: IResourceMetaRaw
|
||||||
|
}
|
||||||
|
// This is the "raw" Fhir Bundle resource
|
||||||
|
export interface IResourceBundleRaw {
|
||||||
|
resourceType: string
|
||||||
|
id?: string
|
||||||
|
entry: IResourceBundleEntryRaw[]
|
||||||
|
total?: number
|
||||||
|
link?: IResourceBundleLinkRaw[]
|
||||||
|
meta?: IResourceMetaRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResourceBundleLinkRaw {
|
||||||
|
id?: string
|
||||||
|
relation: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResourceBundleEntryRaw {
|
||||||
|
id?: string
|
||||||
|
fullUrl?: string
|
||||||
|
resource: IResourceRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResourceMetaRaw {
|
||||||
|
id?: string
|
||||||
|
versionId?: string
|
||||||
|
lastUpdated: string
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import {Source} from '../database/source';
|
import {Source} from './source';
|
||||||
import {ResourceFhir} from '../database/resource_fhir';
|
import {ResourceFhir} from './resource_fhir';
|
||||||
|
|
||||||
export class ResourceTypeCounts {
|
export class ResourceTypeCounts {
|
||||||
count: number
|
count: number
|
|
@ -0,0 +1,18 @@
|
||||||
|
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
||||||
|
|
||||||
|
export class Source extends LighthouseSourceMetadata{
|
||||||
|
id?: string
|
||||||
|
user_id?: number
|
||||||
|
source_type: string
|
||||||
|
|
||||||
|
patient: string
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string
|
||||||
|
id_token?: string
|
||||||
|
expires_at: number //seconds since epoch
|
||||||
|
|
||||||
|
constructor(object: any) {
|
||||||
|
super()
|
||||||
|
return Object.assign(this, object)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import {Source} from '../database/source';
|
import {Source} from './source';
|
||||||
import {ResourceTypeCounts} from './source-summary';
|
import {ResourceTypeCounts} from './source-summary';
|
||||||
import {ResourceFhir} from '../database/resource_fhir';
|
import {ResourceFhir} from './resource_fhir';
|
||||||
|
|
||||||
export class Summary {
|
export class Summary {
|
||||||
sources: Source[]
|
sources: Source[]
|
|
@ -1,4 +1,5 @@
|
||||||
export class User {
|
export class User {
|
||||||
|
user_id?: number
|
||||||
full_name?: string
|
full_name?: string
|
||||||
username?: string
|
username?: string
|
||||||
password?: string
|
password?: string
|
|
@ -1,4 +1,4 @@
|
||||||
import {Source} from '../../../lib/models/database/source';
|
import {Source} from '../../models/fasten/source';
|
||||||
|
|
||||||
export class SourceSyncMessage {
|
export class SourceSyncMessage {
|
||||||
source: Source
|
source: Source
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, OnInit} from '@angular/core';
|
||||||
import {User} from '../../../lib/models/fasten/user';
|
import {User} from '../../models/fasten/user';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
|
||||||
import {ActivatedRoute, Router} from '@angular/router';
|
import {ActivatedRoute, Router} from '@angular/router';
|
||||||
import {ToastService} from '../../services/toast.service';
|
import {ToastService} from '../../services/toast.service';
|
||||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||||
import {environment} from '../../../environments/environment';
|
import {environment} from '../../../environments/environment';
|
||||||
import {AuthService} from '../../services/auth.service';
|
import {AuthService} from '../../services/auth.service';
|
||||||
import {Location} from '@angular/common';
|
import {Location} from '@angular/common';
|
||||||
import {PouchdbCrypto} from '../../../lib/database/plugins/crypto';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-auth-signin',
|
selector: 'app-auth-signin',
|
||||||
|
@ -23,7 +21,6 @@ export class AuthSigninComponent implements OnInit {
|
||||||
loading: boolean = false
|
loading: boolean = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fastenDb: FastenDbService,
|
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
@ -47,10 +44,7 @@ export class AuthSigninComponent implements OnInit {
|
||||||
//TODO: replace Pouchdb.
|
//TODO: replace Pouchdb.
|
||||||
let userId = this.authService.GetCurrentUser().sub
|
let userId = this.authService.GetCurrentUser().sub
|
||||||
//TODO: static IV, must be removed/replaced.
|
//TODO: static IV, must be removed/replaced.
|
||||||
return {username: userId, key: userId, config: "WyI3NUhJcEhZTXBNVXRtMHlJcnBMckhRPT0iLHsic2FsdExlbmd0aCI6MTYsIm1lbW9yeVNpemUiOjQwOTYsIml0ZXJhdGlvbnMiOjEwMCwicGFyYWxsZWxpc20iOjF9XQ=="}
|
return {username: userId, key: userId}
|
||||||
})
|
|
||||||
.then((cryptoConfig) => {
|
|
||||||
PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
|
||||||
})
|
})
|
||||||
.then(() => this.router.navigateByUrl('/dashboard'))
|
.then(() => this.router.navigateByUrl('/dashboard'))
|
||||||
.catch((err)=>{
|
.catch((err)=>{
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
import {User} from '../../models/fasten/user';
|
||||||
import {User} from '../../../lib/models/fasten/user';
|
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||||
import {ToastService} from '../../services/toast.service';
|
import {ToastService} from '../../services/toast.service';
|
||||||
|
|
|
@ -148,7 +148,7 @@
|
||||||
<div class="media-body">
|
<div class="media-body">
|
||||||
<h5>{{metadataSource[source.source_type]?.display}}</h5>
|
<h5>{{metadataSource[source.source_type]?.display}}</h5>
|
||||||
<p>
|
<p>
|
||||||
{{getPatientSummary(patientForSource[source._id]?.resource_raw)}}
|
{{getPatientSummary(patientForSource[source.id]?.resource_raw)}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
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 {Router} from '@angular/router';
|
||||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||||
import {forkJoin} from 'rxjs';
|
import {forkJoin} from 'rxjs';
|
||||||
import {MetadataSource} from '../../models/fasten/metadata-source';
|
import {MetadataSource} from '../../models/fasten/metadata-source';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
import {FastenApiService} from '../../services/fasten-api.service';
|
||||||
import {Summary} from '../../../lib/models/fasten/summary';
|
import {Summary} from '../../models/fasten/summary';
|
||||||
import {Base64} from '../../../lib/utils/base64';
|
|
||||||
import {LighthouseService} from '../../services/lighthouse.service';
|
import {LighthouseService} from '../../services/lighthouse.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -25,7 +24,7 @@ export class DashboardComponent implements OnInit {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private lighthouseApi: LighthouseService,
|
private lighthouseApi: LighthouseService,
|
||||||
private fastenDb: FastenDbService,
|
private fastenApi: FastenApiService,
|
||||||
private router: Router
|
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 summary = results[0] as Summary
|
||||||
let metadataSource = results[1] as { [name: string]: MetadataSource }
|
let metadataSource = results[1] as { [name: string]: MetadataSource }
|
||||||
|
|
||||||
|
@ -81,7 +80,7 @@ export class DashboardComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
selectSource(selectedSource: Source){
|
selectSource(selectedSource: Source){
|
||||||
this.router.navigateByUrl(`/source/${Base64.Encode(selectedSource._id)}`, {
|
this.router.navigateByUrl(`/source/${selectedSource.id}`, {
|
||||||
state: selectedSource
|
state: selectedSource
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,151 +0,0 @@
|
||||||
<div class="az-content az-content-profile">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row w-100">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<h2 class="az-content-title mg-t-20">Security Manager</h2>
|
|
||||||
<p class="mb-5">
|
|
||||||
Before you use Fasten you'll need to import or generate a new encryption key.
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
Fasten uses <a href="https://en.wikipedia.org/wiki/Zero-knowledge_service" target="_blank">zero-knowledge encryption</a> to secure your medical data.
|
|
||||||
This means that your medical records are encrypted on your device, before they are stored in the Fasten database.
|
|
||||||
The encrypted data stored in the database is worthless without your encryption key.
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
You must safely store your encryption key as you would a username & password,
|
|
||||||
as it's the only thing that will allow you to access your records.</p>
|
|
||||||
|
|
||||||
<div *ngIf="cryptoPanel == CryptoPanelType.Generate" class="wizard clearfix">
|
|
||||||
<div class="steps clearfix">
|
|
||||||
<ul role="tablist">
|
|
||||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
|
||||||
<a aria-controls="wizard1-p-0">
|
|
||||||
<span class="number">1</span>
|
|
||||||
<span class="title">Download</span></a>
|
|
||||||
</li>
|
|
||||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
|
||||||
<a aria-controls="wizard1-p-2">
|
|
||||||
<span class="number">2</span>
|
|
||||||
<span class="title">Validate</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="content clearfix">
|
|
||||||
<ng-container [ngSwitch]="currentStep">
|
|
||||||
<ng-container *ngSwitchCase="1">
|
|
||||||
<h3 tabindex="-1" class="title current">Generate an encryption key</h3>
|
|
||||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
|
||||||
<p class="mg-b-0">
|
|
||||||
Fasten has generated an encryption key for you. You can use this encryption key to decode your medical records on this browser, and other devices.
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
This is the only time the encryption key will be available to view, copy or download. We recommend downloading this key and storing the file in a secure location.
|
|
||||||
<br/>
|
|
||||||
You can reset your encryption key at any time, however any previously encrypted data will no longer be accessible.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<pre><code [highlight]="currentCryptoConfig | json"></code></pre>
|
|
||||||
|
|
||||||
<div class="row row-xs wd-xl-80p">
|
|
||||||
<div class="col-sm-6 col-md-3">
|
|
||||||
<a [href]="generateCryptoConfigUrl" [download]="generateCryptoConfigFilename" class="btn btn-warning btn-rounded btn-block">Download Encryption Key</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="2">
|
|
||||||
<h3 tabindex="-1" class="title current">Validate your encryption key</h3>
|
|
||||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
|
||||||
<p class="mg-b-10">
|
|
||||||
Please select your encryption key (which you generated in the previous step) using the file input below.
|
|
||||||
<br/>
|
|
||||||
It'll be validated against your browser's encryption key to ensure fidelity.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="custom-file">
|
|
||||||
<input type="file" class="custom-file-input" id="generateCustomFile" (change)="generateOpenFileHandler($event.target.files)" accept="application/json">
|
|
||||||
<label class="custom-file-label" for="generateCustomFile">Choose file</label>
|
|
||||||
<div *ngIf="generateCustomFileError" class="alert alert-danger">
|
|
||||||
{{generateCustomFileError}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="currentStep != lastStep" class="actions clearfix">
|
|
||||||
<ul role="menu" aria-label="Pagination">
|
|
||||||
<li class="disabled" aria-disabled="true"></li>
|
|
||||||
<li aria-disabled="false"><button class="btn btn-az-primary btn-block" (click)="nextHandler()" role="menuitem">Next</button></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div *ngIf="cryptoPanel == CryptoPanelType.Import" class="wizard clearfix">
|
|
||||||
<div class="steps clearfix">
|
|
||||||
<ul role="tablist">
|
|
||||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
|
||||||
<a aria-controls="wizard1-p-0">
|
|
||||||
<span class="number">1</span>
|
|
||||||
<span class="title">Import</span></a>
|
|
||||||
</li>
|
|
||||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
|
||||||
<a aria-controls="wizard1-p-2">
|
|
||||||
<span class="number">2</span>
|
|
||||||
<span class="title">Validate</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="content clearfix">
|
|
||||||
<ng-container [ngSwitch]="currentStep">
|
|
||||||
<ng-container *ngSwitchCase="1">
|
|
||||||
<h3 tabindex="-1" class="title current">Import existing encryption key</h3>
|
|
||||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
|
||||||
<p class="mg-b-10">
|
|
||||||
Fasten was unable to find your encryption key on this device, and has detected encrypted data in your database.
|
|
||||||
<br/>
|
|
||||||
You will need to provide your encryption key to access your health records.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="custom-file">
|
|
||||||
<input type="file" class="custom-file-input" id="importCustomFile" (change)="importOpenFileHandler($event.target.files)" accept="application/json">
|
|
||||||
<label class="custom-file-label" for="importCustomFile">Choose file</label>
|
|
||||||
<div *ngIf="importCustomFileError" class="alert alert-danger">
|
|
||||||
{{importCustomFileError}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="2">
|
|
||||||
<h3 tabindex="-1" class="title current">Validate encryption key</h3>
|
|
||||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
|
||||||
<p class="mg-b-10">
|
|
||||||
Thank you for providing your encryption key. Fasten will attempt to decrypt your secured records with this key.
|
|
||||||
</p>
|
|
||||||
<div class="spinner-border" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { EncryptionManagerComponent } from './encryption-manager.component';
|
|
||||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
|
||||||
|
|
||||||
describe('EncryptionManagerComponent', () => {
|
|
||||||
let component: EncryptionManagerComponent;
|
|
||||||
let fixture: ComponentFixture<EncryptionManagerComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ EncryptionManagerComponent ],
|
|
||||||
imports: [HttpClientTestingModule],
|
|
||||||
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(EncryptionManagerComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,198 +0,0 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import {PouchdbCryptConfig, PouchdbCrypto} from '../../../lib/database/plugins/crypto';
|
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
|
||||||
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
|
|
||||||
import {Router} from '@angular/router';
|
|
||||||
import {ToastService} from '../../services/toast.service';
|
|
||||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
|
||||||
|
|
||||||
export enum CryptoPanelType {
|
|
||||||
Loading,
|
|
||||||
Import,
|
|
||||||
Generate,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-encryption-manager',
|
|
||||||
templateUrl: './encryption-manager.component.html',
|
|
||||||
styleUrls: ['./encryption-manager.component.scss']
|
|
||||||
})
|
|
||||||
export class EncryptionManagerComponent implements OnInit {
|
|
||||||
CryptoPanelType = CryptoPanelType
|
|
||||||
cryptoPanel: CryptoPanelType = CryptoPanelType.Loading
|
|
||||||
currentCryptoConfig: PouchdbCryptConfig = null
|
|
||||||
|
|
||||||
generateCryptoConfigUrl: SafeResourceUrl = ""
|
|
||||||
generateCryptoConfigFilename: string = ""
|
|
||||||
generateCustomFileError: string = ""
|
|
||||||
|
|
||||||
importCustomFileError: string = ""
|
|
||||||
|
|
||||||
currentStep: number
|
|
||||||
lastStep: number
|
|
||||||
|
|
||||||
|
|
||||||
constructor(private fastenDbService: FastenDbService, private sanitizer: DomSanitizer, private router: Router, private toastService: ToastService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
|
|
||||||
this.fastenDbService.IsDatabasePopulated()
|
|
||||||
.then((isPopulated) => {
|
|
||||||
if(isPopulated){
|
|
||||||
return this.showImportCryptoConfig()
|
|
||||||
} else {
|
|
||||||
return this.showGenerateCryptoConfig()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
nextHandler() {
|
|
||||||
this.currentStep += 1
|
|
||||||
// if (!this.stepsService.isLastStep()) {
|
|
||||||
// this.stepsService.moveToNextStep();
|
|
||||||
// } else {
|
|
||||||
// this.onSubmit();
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Generate Wizard Methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
async showGenerateCryptoConfig(): Promise<PouchdbCryptConfig> {
|
|
||||||
this.cryptoPanel = CryptoPanelType.Generate
|
|
||||||
this.currentStep = 1
|
|
||||||
this.lastStep = 2
|
|
||||||
if(!this.currentCryptoConfig){
|
|
||||||
this.currentCryptoConfig = await PouchdbCrypto.CryptConfig(uuidv4(), this.fastenDbService.current_user)
|
|
||||||
await PouchdbCrypto.StoreCryptConfig(this.currentCryptoConfig) //store in indexdb
|
|
||||||
|
|
||||||
|
|
||||||
//generate URL for downloading.
|
|
||||||
let currentCryptoConfigBlob = new Blob([JSON.stringify(this.currentCryptoConfig)], { type: 'application/json' });
|
|
||||||
this.generateCryptoConfigUrl = this.sanitizer.bypassSecurityTrustResourceUrl(window.URL.createObjectURL(currentCryptoConfigBlob));
|
|
||||||
this.generateCryptoConfigFilename = `fasten-${this.fastenDbService.current_user}.key.json`
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.currentCryptoConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
generateOpenFileHandler(fileList: FileList) {
|
|
||||||
this.generateCustomFileError = ""
|
|
||||||
let file = fileList[0];
|
|
||||||
this.readFileContent(file)
|
|
||||||
.then((content) => {
|
|
||||||
let parsedCryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
|
||||||
|
|
||||||
//check if the parsed encryption key matches the currently set encryption key
|
|
||||||
|
|
||||||
if(parsedCryptoConfig.key == this.currentCryptoConfig.key &&
|
|
||||||
parsedCryptoConfig.username == this.currentCryptoConfig.username &&
|
|
||||||
parsedCryptoConfig.config == this.currentCryptoConfig.config){
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
//throw an error & notify user
|
|
||||||
this.generateCustomFileError = "Crypto configuration file does not match"
|
|
||||||
throw new Error(this.generateCustomFileError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
|
|
||||||
const toastNotification = new ToastNotification()
|
|
||||||
toastNotification.type = ToastType.Success
|
|
||||||
toastNotification.message = "Successfully validated & stored encryption key."
|
|
||||||
toastNotification.autohide = true
|
|
||||||
this.toastService.show(toastNotification)
|
|
||||||
|
|
||||||
//redirect user to dashboard
|
|
||||||
return this.router.navigate(['/dashboard']);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
// delete invalid encryption key
|
|
||||||
this.currentStep = 1
|
|
||||||
return PouchdbCrypto.DeleteCryptConfig(this.fastenDbService.current_user)
|
|
||||||
.then(() => {
|
|
||||||
//an error occurred while importing credential
|
|
||||||
const toastNotification = new ToastNotification()
|
|
||||||
toastNotification.type = ToastType.Error
|
|
||||||
toastNotification.message = "Provided encryption key does not match. Generating new encryption key, please store it and try again."
|
|
||||||
toastNotification.autohide = false
|
|
||||||
this.toastService.show(toastNotification)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Import Wizard Methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
async showImportCryptoConfig(): Promise<any> {
|
|
||||||
this.cryptoPanel = CryptoPanelType.Import
|
|
||||||
this.currentStep = 1
|
|
||||||
this.lastStep = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
importOpenFileHandler(fileList: FileList) {
|
|
||||||
this.importCustomFileError = ""
|
|
||||||
let file = fileList[0];
|
|
||||||
this.readFileContent(file)
|
|
||||||
.then((content) => {
|
|
||||||
let cryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
|
||||||
|
|
||||||
if(!cryptoConfig.key || !cryptoConfig.config){
|
|
||||||
this.importCustomFileError = "Invalid crypto configuration file"
|
|
||||||
throw new Error(this.importCustomFileError)
|
|
||||||
}
|
|
||||||
|
|
||||||
return PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
//go to step 2
|
|
||||||
this.currentStep = 2
|
|
||||||
//attempt to initialize pouchdb with specified crypto
|
|
||||||
this.fastenDbService.ResetDB()
|
|
||||||
return this.fastenDbService.GetSources()
|
|
||||||
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
const toastNotification = new ToastNotification()
|
|
||||||
toastNotification.type = ToastType.Success
|
|
||||||
toastNotification.message = "Successfully validated & imported encryption key."
|
|
||||||
toastNotification.autohide = true
|
|
||||||
this.toastService.show(toastNotification)
|
|
||||||
|
|
||||||
return this.router.navigate(['/dashboard']);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err)
|
|
||||||
// delete invalid encryption key
|
|
||||||
this.currentStep = 1
|
|
||||||
return PouchdbCrypto.DeleteCryptConfig(this.fastenDbService.current_user)
|
|
||||||
.then(() => {
|
|
||||||
//an error occurred while importing credential
|
|
||||||
const toastNotification = new ToastNotification()
|
|
||||||
toastNotification.type = ToastType.Error
|
|
||||||
toastNotification.message = "Provided encryption key does not match. Please try a different key"
|
|
||||||
toastNotification.autohide = false
|
|
||||||
this.toastService.show(toastNotification)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private readFileContent(file: File): Promise<string>{
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
if (!file) {
|
|
||||||
resolve('');
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const text = reader.result.toString();
|
|
||||||
resolve(text);
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
<div class="az-content">
|
||||||
|
<div class="container">
|
||||||
|
<div class="az-content-body">
|
||||||
|
|
||||||
|
<!-- Header Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 bg-indigo tx-white d-flex align-items-center">
|
||||||
|
<h1>Medical History</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 mt-2 mb-2 tx-12">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Patient:</strong> Caldwell, Ruben<br/>
|
||||||
|
<strong>Address:</strong> 123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||||
|
<strong>Date of Birth:</strong> June 20, 1929<br/>
|
||||||
|
<strong>Phone:</strong> 415-343-2342<br/>
|
||||||
|
<strong>Email:</strong> myemail@gmail.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 tx-indigo mt-2 mb-2 tx-12">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Primary Care:</strong> Bishop, J. ANRP<br/>
|
||||||
|
<strong>Address:</strong> Malcom Randall VA <br/>Medical Center Gainsville FL<br/>
|
||||||
|
<strong>Phone:</strong> 123-321-5532<br/>
|
||||||
|
<strong>Email:</strong> myemail@va.com<br/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conditions Title -->
|
||||||
|
<div class="row mt-5 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<h1>Condition</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 tx-indigo">
|
||||||
|
<h1>History</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Condition List -->
|
||||||
|
|
||||||
|
<div class="row bg-indigo tx-white pt-1 pb-1">
|
||||||
|
<!-- Condition Header -->
|
||||||
|
<div class="col-6 d-flex align-items-center">
|
||||||
|
<h3 class="mb-0">Gout</h3>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 d-flex align-items-center">
|
||||||
|
<h3 class="mb-0">Nov 16, 2002 - Present</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Condition Details -->
|
||||||
|
|
||||||
|
<div class="col-6 mb-2">
|
||||||
|
|
||||||
|
<div class="row pl-3">
|
||||||
|
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||||
|
<h5>Involved in Care</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong>James Bishop</strong>, ANRP
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Primary Care
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong>Matthew Leonard</strong>, MD
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Diagnosing Physician
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong>Stephanie Wrenn</strong>, MD
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Dietitian
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||||
|
<h5>Initial Presentation</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
Acute right knee pain and tenderness around the joint line - this was likely caused by acute renal failure.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-2 bg-gray-100">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||||
|
<h5>Nov 19, 2012</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-2 mb-2">
|
||||||
|
<strong>Medications:</strong> Colchicine, as needed for gout attacks
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-12 mt-3 mb-2 tx-indigo">
|
||||||
|
<h5>Nov 16, 2012</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-2 mb-2">
|
||||||
|
<strong>Procedures:</strong> The fluid in your right knee was drained.
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-2 mb-2">
|
||||||
|
<strong>Tests and Examinations:</strong> The fluid tested prositive for gout crystals
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-2 mb-2">
|
||||||
|
<strong>Medications:</strong> You were given a steroid injection to reduce inflammation and as short course prednistone to reduce pain and inflammation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MedicalHistoryComponent } from './medical-history.component';
|
||||||
|
|
||||||
|
describe('MedicalHistoryComponent', () => {
|
||||||
|
let component: MedicalHistoryComponent;
|
||||||
|
let fixture: ComponentFixture<MedicalHistoryComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ MedicalHistoryComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MedicalHistoryComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-medical-history',
|
||||||
|
templateUrl: './medical-history.component.html',
|
||||||
|
styleUrls: ['./medical-history.component.scss']
|
||||||
|
})
|
||||||
|
export class MedicalHistoryComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -30,7 +30,7 @@
|
||||||
<div *ngFor="let sourceInfo of connectedSourceList" class="col-sm-3 mg-b-20 px-3">
|
<div *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 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">
|
<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 *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 [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>
|
</div>
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, OnInit} from '@angular/core';
|
||||||
import {LighthouseService} from '../../services/lighthouse.service';
|
import {LighthouseService} from '../../services/lighthouse.service';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
import {FastenApiService} from '../../services/fasten-api.service';
|
||||||
import {LighthouseSourceMetadata} from '../../../lib/models/lighthouse/lighthouse-source-metadata';
|
import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-source-metadata';
|
||||||
import {Source} from '../../../lib/models/database/source';
|
import {Source} from '../../models/fasten/source';
|
||||||
import {getAccessTokenExpiration, jwtDecode} from 'fhirclient/lib/lib';
|
import {getAccessTokenExpiration, jwtDecode} from 'fhirclient/lib/lib';
|
||||||
import BrowserAdapter from 'fhirclient/lib/adapters/BrowserAdapter';
|
import BrowserAdapter from 'fhirclient/lib/adapters/BrowserAdapter';
|
||||||
import {MetadataSource} from '../../models/fasten/metadata-source';
|
import {MetadataSource} from '../../models/fasten/metadata-source';
|
||||||
import {ModalDismissReasons, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
import {ModalDismissReasons, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {ActivatedRoute, Router} from '@angular/router';
|
import {ActivatedRoute, Router} from '@angular/router';
|
||||||
import {Location} from '@angular/common';
|
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 {ToastService} from '../../services/toast.service';
|
||||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||||
import {SourceSyncMessage} from '../../models/queue/source-sync-message';
|
import {SourceSyncMessage} from '../../models/queue/source-sync-message';
|
||||||
import {UpsertSummary} from '../../../lib/models/fasten/upsert-summary';
|
|
||||||
import {environment} from '../../../environments/environment';
|
import {environment} from '../../../environments/environment';
|
||||||
// If you dont import this angular will import the wrong "Location"
|
// If you dont import this angular will import the wrong "Location"
|
||||||
|
|
||||||
|
@ -35,15 +32,15 @@ export class MedicalSourcesComponent implements OnInit {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private lighthouseApi: LighthouseService,
|
private lighthouseApi: LighthouseService,
|
||||||
private fastenDb: FastenDbService,
|
private fastenApi: FastenApiService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private location: Location,
|
private location: Location,
|
||||||
private queueService: QueueService,
|
|
||||||
private toastService: ToastService
|
private toastService: ToastService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
environment_name = environment.environment_name
|
environment_name = environment.environment_name
|
||||||
status: { [name: string]: string } = {}
|
status: { [name: string]: string } = {}
|
||||||
|
|
||||||
|
@ -66,9 +63,9 @@ export class MedicalSourcesComponent implements OnInit {
|
||||||
this.callback(callbackSourceType).then(console.log)
|
this.callback(callbackSourceType).then(console.log)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fastenDb.GetSources()
|
this.fastenApi.getSources()
|
||||||
.then((paginatedList) => {
|
.subscribe((paginatedList: Source[]) => {
|
||||||
const sourceList = paginatedList.rows as Source[]
|
const sourceList = paginatedList as Source[]
|
||||||
|
|
||||||
for (const sourceType in this.metadataSources) {
|
for (const sourceType in this.metadataSources) {
|
||||||
let isConnected = false
|
let isConnected = false
|
||||||
|
@ -167,7 +164,7 @@ export class MedicalSourcesComponent implements OnInit {
|
||||||
//Create FHIR Client
|
//Create FHIR Client
|
||||||
|
|
||||||
const dbSourceCredential = new Source({
|
const dbSourceCredential = new Source({
|
||||||
source_type: sourceType as SourceType,
|
source_type: sourceType,
|
||||||
|
|
||||||
authorization_endpoint: sourceMetadata.authorization_endpoint,
|
authorization_endpoint: sourceMetadata.authorization_endpoint,
|
||||||
token_endpoint: sourceMetadata.token_endpoint,
|
token_endpoint: sourceMetadata.token_endpoint,
|
||||||
|
@ -194,9 +191,44 @@ export class MedicalSourcesComponent implements OnInit {
|
||||||
expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())),
|
expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())),
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.fastenDb.UpsertSource(dbSourceCredential).then(console.log)
|
this.fastenApi.createSource(dbSourceCredential)
|
||||||
this.queueSourceSyncWorker(sourceType as SourceType, 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) {
|
public uploadSourceBundleHandler(event) {
|
||||||
this.uploadedFile = [event.addedFiles[0]]
|
this.uploadedFile = [event.addedFiles[0]]
|
||||||
//TODO: handle manual bundles.
|
//TODO: handle manual bundles.
|
||||||
// this.fastenDb.CreateManualSource(event.addedFiles[0]).subscribe(
|
this.fastenApi.createManualSource(event.addedFiles[0]).subscribe(
|
||||||
// (respData) => {
|
(respData) => {
|
||||||
// console.log("source manual source create response:", respData)
|
console.log("source manual source create response:", respData)
|
||||||
// },
|
},
|
||||||
// (err) => {console.log(err)},
|
(err) => {console.log(err)},
|
||||||
// () => {
|
() => {
|
||||||
// this.uploadedFile = []
|
this.uploadedFile = []
|
||||||
// }
|
}
|
||||||
// )
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public openModal(contentModalRef, sourceListItem: SourceListItem) {
|
public openModal(contentModalRef, sourceListItem: SourceListItem) {
|
||||||
|
@ -234,56 +266,22 @@ export class MedicalSourcesComponent implements OnInit {
|
||||||
this.status[source.source_type] = "authorize"
|
this.status[source.source_type] = "authorize"
|
||||||
this.modalService.dismissAll()
|
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
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////
|
||||||
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 {
|
private getDismissReason(reason: any): string {
|
||||||
if (reason === ModalDismissReasons.ESC) {
|
if (reason === ModalDismissReasons.ESC) {
|
||||||
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
<div class="az-content">
|
||||||
|
<div class="container">
|
||||||
|
<div class="az-content-body">
|
||||||
|
|
||||||
|
<!-- Header Row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 bg-indigo tx-white d-flex align-items-center">
|
||||||
|
<h1>Patient Profile</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 mt-2 mb-2 tx-12">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Patient:</strong> Caldwell, Ruben<br/>
|
||||||
|
<strong>Address:</strong> 123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||||
|
<strong>Date of Birth:</strong> June 20, 1929<br/>
|
||||||
|
<strong>Phone:</strong> 415-343-2342<br/>
|
||||||
|
<strong>Email:</strong> myemail@gmail.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 tx-indigo mt-2 mb-2 tx-12">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Primary Care:</strong> Bishop, J. ANRP<br/>
|
||||||
|
<strong>Address:</strong> Malcom Randall VA <br/>Medical Center Gainsville FL<br/>
|
||||||
|
<strong>Phone:</strong> 123-321-5532<br/>
|
||||||
|
<strong>Email:</strong> myemail@va.com<br/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-3 pr-3">
|
||||||
|
<!-- Patient Name Row -->
|
||||||
|
<div class="row mt-5 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<h1>Caldwell, Ruben</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Details -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<h2 class="tx-indigo">Patient</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">First Name:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Ruben
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Last Name:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Caldwell
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Gender:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
M
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Martial Status:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Married
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Religious Affil:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
N/A
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Ethnicity:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
White/Caucation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Language:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
English
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Address:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
123 B Street<br/>Gainsville, FL, 94153<br/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Date of Birth:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
June 20, 1929
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Phone:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
415-343-2342
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Email:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
myemail@gmail.com
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h2 class="tx-indigo">Care Provider</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Primary Care:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Bishop, J. ANRP
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Address:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
Malcom Randall VA <br/>Medical Center Gainsville FL
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Phone:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
123-321-5532
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<strong class="tx-indigo">Email:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
myemail@va.com
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Immunizations & Allergies -->
|
||||||
|
<div class="row mt-5">
|
||||||
|
<div class="col-6">
|
||||||
|
<h2 class="tx-indigo">Immunizations</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong class="tx-indigo">Phenumovax</strong><br/>
|
||||||
|
07/09/2022<br/>
|
||||||
|
<br/>
|
||||||
|
<strong class="tx-indigo">Hepatitis B Series [complete]</strong><br/>
|
||||||
|
01/12/2005<br/>
|
||||||
|
<br/>
|
||||||
|
<strong class="tx-indigo">Tetanus Toxoid [complete]</strong><br/>
|
||||||
|
02/02/2010<br/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h2 class="tx-indigo">Allergies</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong class="tx-indigo">Latex</strong><br/>
|
||||||
|
(AV/Historical) with rash and agitation<br/>
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PatientProfileComponent } from './patient-profile.component';
|
||||||
|
|
||||||
|
describe('PatientProfileComponent', () => {
|
||||||
|
let component: PatientProfileComponent;
|
||||||
|
let fixture: ComponentFixture<PatientProfileComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ PatientProfileComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PatientProfileComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-profile',
|
||||||
|
templateUrl: './patient-profile.component.html',
|
||||||
|
styleUrls: ['./patient-profile.component.scss']
|
||||||
|
})
|
||||||
|
export class PatientProfileComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,186 +0,0 @@
|
||||||
|
|
||||||
<div class=" content">
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-8">
|
|
||||||
<div class=" card">
|
|
||||||
<div class=" card-header"><h5 class=" title">Edit Profile</h5></div>
|
|
||||||
<div class=" card-body">
|
|
||||||
<form>
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-5 pr-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Company (disabled) </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
disabled=""
|
|
||||||
placeholder="Company"
|
|
||||||
type="text"
|
|
||||||
value="Creative Code Inc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-3 px-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Username </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="Username"
|
|
||||||
type="text"
|
|
||||||
value="michael23"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-4 pl-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label for="exampleInputEmail1"> Email address </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="mike@email.com"
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-6 pr-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> First Name </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="Company"
|
|
||||||
type="text"
|
|
||||||
value="Mike"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-6 pl-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Last Name </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="Last Name"
|
|
||||||
type="text"
|
|
||||||
value="Andrew"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-12">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Address </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="Home Address"
|
|
||||||
type="text"
|
|
||||||
value="Bld Mihail Kogalniceanu, nr. 8 Bl 1, Sc 1, Ap 09"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-4 pr-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> City </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="City"
|
|
||||||
type="text"
|
|
||||||
value="Mike"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-4 px-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Country </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="Country"
|
|
||||||
type="text"
|
|
||||||
value="Andrew"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-4 pl-md-1">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> Postal Code </label>
|
|
||||||
<input
|
|
||||||
class=" form-control"
|
|
||||||
placeholder="ZIP Code"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" row">
|
|
||||||
<div class=" col-md-8">
|
|
||||||
<div class=" form-group">
|
|
||||||
<label> About Me </label>
|
|
||||||
<textarea
|
|
||||||
class=" form-control"
|
|
||||||
cols="80"
|
|
||||||
placeholder="Here can be your description"
|
|
||||||
rows="4"
|
|
||||||
value="Mike"
|
|
||||||
>
|
|
||||||
Lamborghini Mercy, Your chick she so thirsty, I'm in that two seat Lambo.
|
|
||||||
</textarea
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class=" card-footer">
|
|
||||||
<button class=" btn btn-fill btn-danger" type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" col-md-4">
|
|
||||||
<div class=" card card-user">
|
|
||||||
<div class=" card-body">
|
|
||||||
<p class=" card-text"></p>
|
|
||||||
<div class=" author">
|
|
||||||
<div class=" block block-one"></div>
|
|
||||||
<div class=" block block-two"></div>
|
|
||||||
<div class=" block block-three"></div>
|
|
||||||
<div class=" block block-four"></div>
|
|
||||||
<a href="javascript:void(0)">
|
|
||||||
<img alt="..." class=" avatar" src="assets/img/emilyz.jpg" />
|
|
||||||
|
|
||||||
<h5 class=" title">Mike Andrew</h5>
|
|
||||||
</a>
|
|
||||||
<p class=" description">Ceo/Co-Founder</p>
|
|
||||||
</div>
|
|
||||||
<div class=" card-description">
|
|
||||||
Do not be scared of the truth because we need to restart the human
|
|
||||||
foundation in truth And I love you like Kanye loves Kanye I love
|
|
||||||
Rick Owens’ bed design but the back is...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=" card-footer">
|
|
||||||
<div class=" button-container">
|
|
||||||
<button
|
|
||||||
class=" btn btn-icon btn-round btn-facebook"
|
|
||||||
href="javascript:void(0)"
|
|
||||||
>
|
|
||||||
<i class=" fab fa-facebook"> </i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class=" btn btn-icon btn-round btn-twitter"
|
|
||||||
href="javascript:void(0)"
|
|
||||||
>
|
|
||||||
<i class=" fab fa-twitter"> </i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class=" btn btn-icon btn-round btn-google"
|
|
||||||
href="javascript:void(0)"
|
|
||||||
>
|
|
||||||
<i class=" fab fa-google-plus"> </i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { Component, OnInit } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-patient",
|
|
||||||
templateUrl: "patient.component.html"
|
|
||||||
})
|
|
||||||
export class PatientComponent implements OnInit {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
ngOnInit() {}
|
|
||||||
}
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { 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 {ActivatedRoute, Router} from '@angular/router';
|
||||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||||
import {Base64} from '../../../lib/utils/base64';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-resource-detail',
|
selector: 'app-resource-detail',
|
||||||
|
@ -14,23 +13,16 @@ export class ResourceDetailComponent implements OnInit {
|
||||||
sourceName: string = ""
|
sourceName: string = ""
|
||||||
resource: ResourceFhir = null
|
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 {
|
ngOnInit(): void {
|
||||||
//always request the resource by id
|
this.fastenApi.getResourceBySourceId(this.route.snapshot.paramMap.get('source_id'), this.route.snapshot.paramMap.get('resource_id')).subscribe((resourceFhir) => {
|
||||||
let resourceId = Base64.Decode(this.route.snapshot.paramMap.get('resource_id'))
|
console.log("RESOURECE FHIR", resourceFhir)
|
||||||
if (resourceId){
|
this.resource = resourceFhir;
|
||||||
this.fastenDb.GetResource(resourceId)
|
this.sourceId = this.route.snapshot.paramMap.get('source_id')
|
||||||
.then((resourceFhir) => {
|
this.sourceName = "unknown" //TODO popualte this
|
||||||
this.resource = resourceFhir;
|
});
|
||||||
});
|
|
||||||
this.sourceId = resourceId.split(":")[1]
|
|
||||||
this.sourceName = Base64.Decode(this.sourceId).split(":")[1]
|
|
||||||
} else {
|
|
||||||
console.log("invalid or missing resource id")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<div class="patient-row row">
|
<div class="patient-row row">
|
||||||
<div class="col-7 patient-name"><h3 class="pull-left text-primary">{{getPatientName()}}</h3></div>
|
<div class="col-7 patient-name"><h3 class="pull-left text-primary">{{getPatientName()}}</h3></div>
|
||||||
<div class="col-5">
|
<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>
|
<i class="fas fa-info-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import {Component, OnInit, ViewChild} from '@angular/core';
|
import {Component, OnInit, ViewChild} from '@angular/core';
|
||||||
import {ActivatedRoute, Router} from '@angular/router';
|
import {ActivatedRoute, Router} from '@angular/router';
|
||||||
import {Source} from '../../../lib/models/database/source';
|
import {Source} from '../../models/fasten/source';
|
||||||
import {FastenDbService} from '../../services/fasten-db.service';
|
import {FastenApiService} from '../../services/fasten-api.service';
|
||||||
import {ResourceFhir} from '../../../lib/models/database/resource_fhir';
|
import {ResourceFhir} from '../../models/fasten/resource_fhir';
|
||||||
import {getPath} from '../../components/list-generic-resource/utils';
|
import {getPath} from '../../components/list-generic-resource/utils';
|
||||||
import {Base64} from '../../../lib/utils/base64';
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-source-detail',
|
selector: 'app-source-detail',
|
||||||
|
@ -20,7 +18,7 @@ export class SourceDetailComponent implements OnInit {
|
||||||
|
|
||||||
resourceTypeCounts: { [name: string]: number } = {}
|
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:
|
//check if the current Source was sent over using the router state storage:
|
||||||
if(this.router.getCurrentNavigation()?.extras?.state){
|
if(this.router.getCurrentNavigation()?.extras?.state){
|
||||||
this.selectedSource = this.router.getCurrentNavigation().extras.state as Source
|
this.selectedSource = this.router.getCurrentNavigation().extras.state as Source
|
||||||
|
@ -29,7 +27,7 @@ export class SourceDetailComponent implements OnInit {
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
//always request the source summary
|
//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.selectedSource = sourceSummary.source;
|
||||||
this.selectedPatient = sourceSummary.patient;
|
this.selectedPatient = sourceSummary.patient;
|
||||||
for(let resourceTypeCount of sourceSummary.resource_type_counts){
|
for(let resourceTypeCount of sourceSummary.resource_type_counts){
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Injectable, Injector } from '@angular/core';
|
import { Injectable, Injector } from '@angular/core';
|
||||||
import {HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
|
import {HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
|
||||||
import { FastenDbService } from './fasten-db.service';
|
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {Observable, of, throwError} from 'rxjs';
|
import {Observable, of, throwError} from 'rxjs';
|
||||||
import {catchError} from 'rxjs/operators';
|
import {catchError} from 'rxjs/operators';
|
||||||
|
@ -30,40 +29,61 @@ export class AuthInterceptorService implements HttpInterceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
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 reqUrl = new URL(req.url)
|
||||||
let lighthouseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.lighthouse_api_endpoint_base))
|
let lighthouseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.lighthouse_api_endpoint_base))
|
||||||
let apiUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.fasten_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(
|
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
|
return next.handle(req)
|
||||||
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)
|
// 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
|
||||||
|
|
||||||
|
|
||||||
|
// let authToken = this.authService.GetAuthToken()
|
||||||
|
// if(!authToken){
|
||||||
|
// //no authToken available, lets just handle the request as-is
|
||||||
|
// return next.handle(req)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// //only intercept requests to the Fasten API, Database & Lighthouse, all other requests should be sent as-is
|
||||||
|
// let reqUrl = new URL(req.url)
|
||||||
|
// let lighthouseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.lighthouse_api_endpoint_base))
|
||||||
|
// let apiUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base))
|
||||||
|
//
|
||||||
|
// //skip database, header is sent automatically via PouchDB
|
||||||
|
// // let databaseUrl = new URL(GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base))
|
||||||
|
//
|
||||||
|
// if(
|
||||||
|
// (reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname))
|
||||||
|
// ){
|
||||||
|
// //all requests to the lighthouse require the JWT
|
||||||
|
// console.log("making authorized request...")
|
||||||
|
// // Clone the request to add the new auth header.
|
||||||
|
// const authReq = req.clone({headers: req.headers.set('Authorization', 'Bearer ' + this.authService.GetAuthToken())});
|
||||||
|
// // catch the error, make specific functions for catching specific errors and you can chain through them with more catch operators
|
||||||
|
// return next.handle(authReq).pipe(catchError(x=> this.handleAuthError(x))); //here use an arrow function, otherwise you may get "Cannot read property 'navigate' of undefined" on angular 4.4.2/net core 2/webpack 2.70
|
||||||
|
// }
|
||||||
|
// // else if(){
|
||||||
|
// // //TODO: only CORS requests to the API endpoint require JWT, but they also require a custom header.
|
||||||
|
// //
|
||||||
|
// // //(reqUrl.origin == lighthouseUrl.origin && reqUrl.pathname.startsWith(lighthouseUrl.pathname)) ||
|
||||||
|
// // // () ||
|
||||||
|
// // // (reqUrl.origin == apiUrl.origin && reqUrl.pathname.startsWith(apiUrl.pathname))
|
||||||
|
// //
|
||||||
|
// // }
|
||||||
|
//
|
||||||
|
// return next.handle(req)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||||
import {FastenDbService} from './fasten-db.service';
|
import {User} from '../models/fasten/user';
|
||||||
import {User} from '../../lib/models/fasten/user';
|
|
||||||
import {environment} from '../../environments/environment';
|
import {environment} from '../../environments/environment';
|
||||||
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
||||||
import {ResponseWrapper} from '../models/response-wrapper';
|
import {ResponseWrapper} from '../models/response-wrapper';
|
||||||
import * as Oauth from '@panva/oauth4webapi';
|
import * as Oauth from '@panva/oauth4webapi';
|
||||||
import {SourceState} from '../models/fasten/source-state';
|
import {SourceState} from '../models/fasten/source-state';
|
||||||
import {Session} from '../models/database/session';
|
|
||||||
import * as jose from 'jose';
|
import * as jose from 'jose';
|
||||||
import {UserRegisteredClaims} from '../models/fasten/user-registered-claims';
|
import {UserRegisteredClaims} from '../models/fasten/user-registered-claims';
|
||||||
|
|
||||||
|
@ -137,25 +135,29 @@ export class AuthService {
|
||||||
if(!hasAuthToken){
|
if(!hasAuthToken){
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
//check if the authToken works
|
|
||||||
let databaseEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base)
|
//todo: check if the authToken has expired
|
||||||
try {
|
return true
|
||||||
let resp = await this._httpClient.get<any>(`${databaseEndpointBase}/_session`, {
|
|
||||||
headers: new HttpHeaders({
|
// //check if the authToken has expired.
|
||||||
'Content-Type': 'application/json',
|
// let databaseEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.couchdb_endpoint_base)
|
||||||
Authorization: `Bearer ${authToken}`
|
// try {
|
||||||
})
|
// let resp = await this._httpClient.get<any>(`${databaseEndpointBase}/_session`, {
|
||||||
}).toPromise()
|
// headers: new HttpHeaders({
|
||||||
// logic to check if user is logged in here.
|
// 'Content-Type': 'application/json',
|
||||||
let session = resp as Session
|
// Authorization: `Bearer ${authToken}`
|
||||||
if(!session.ok || session?.info?.authenticated != "jwt" || !session.userCtx?.name){
|
// })
|
||||||
//invalid session, not jwt auth, or username is empty
|
// }).toPromise()
|
||||||
return false
|
// // logic to check if user is logged in here.
|
||||||
}
|
// let session = resp as Session
|
||||||
return true
|
// if(!session.ok || session?.info?.authenticated != "jwt" || !session.userCtx?.name){
|
||||||
} catch (e) {
|
// //invalid session, not jwt auth, or username is empty
|
||||||
return false
|
// return false
|
||||||
}
|
// }
|
||||||
|
// return true
|
||||||
|
// } catch (e) {
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
public GetAuthToken(): string {
|
public GetAuthToken(): string {
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { TestBed } from '@angular/core/testing';
|
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';
|
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||||
|
|
||||||
describe('FastenDbService', () => {
|
describe('FastenApiService', () => {
|
||||||
let service: FastenDbService;
|
let service: FastenApiService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [HttpClientTestingModule],
|
imports: [HttpClientTestingModule],
|
||||||
});
|
});
|
||||||
service = TestBed.inject(FastenDbService);
|
service = TestBed.inject(FastenApiService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import {Observable} from 'rxjs';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import {map} from 'rxjs/operators';
|
||||||
|
import {ResponseWrapper} from '../models/response-wrapper';
|
||||||
|
import {Source} from '../models/fasten/source';
|
||||||
|
import {User} from '../models/fasten/user';
|
||||||
|
import {ResourceFhir} from '../models/fasten/resource_fhir';
|
||||||
|
import {SourceSummary} from '../models/fasten/source-summary';
|
||||||
|
import {Summary} from '../models/fasten/summary';
|
||||||
|
import {MetadataSource} from '../models/fasten/metadata-source';
|
||||||
|
import {AuthService} from './auth.service';
|
||||||
|
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
||||||
|
import {environment} from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FastenApiService {
|
||||||
|
|
||||||
|
constructor(private _httpClient: HttpClient, private router: Router, private authService: AuthService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
SECURE ENDPOINTS
|
||||||
|
*/
|
||||||
|
getSummary(): Observable<Summary> {
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/summary`, )
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("Summary RESPONSE", response)
|
||||||
|
return response.data as Summary
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSource(source: Source): Observable<Source> {
|
||||||
|
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source`, source)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("SOURCE RESPONSE", response)
|
||||||
|
return response.data as Source
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createManualSource(file: File): Observable<Source> {
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/manual`, formData)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("MANUAL SOURCE RESPONSE", response)
|
||||||
|
return response.data as Source
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSources(): Observable<Source[]> {
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source`)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("SOURCE RESPONSE", response)
|
||||||
|
return response.data as Source[]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource(sourceId: string): Observable<Source> {
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}`)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("SOURCE RESPONSE", response)
|
||||||
|
return response.data as Source
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceSummary(sourceId: string): Observable<SourceSummary> {
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}/summary`)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("SOURCE RESPONSE", response)
|
||||||
|
return response.data as SourceSummary
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSource(sourceId: string): Observable<any> {
|
||||||
|
return this._httpClient.post<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/source/${sourceId}/sync`, {})
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("SOURCE RESPONSE", response)
|
||||||
|
return response.data
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getResources(sourceResourceType?: string, sourceID?: string): Observable<ResourceFhir[]> {
|
||||||
|
let queryParams = {}
|
||||||
|
if(sourceResourceType){
|
||||||
|
queryParams["sourceResourceType"] = sourceResourceType
|
||||||
|
}
|
||||||
|
if(sourceID){
|
||||||
|
queryParams["sourceID"] = sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir`, {params: queryParams})
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("RESPONSE", response)
|
||||||
|
return response.data as ResourceFhir[]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getResourceBySourceId(sourceId: string, resourceId: string): Observable<ResourceFhir> {
|
||||||
|
|
||||||
|
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir/${sourceId}/${resourceId}`)
|
||||||
|
.pipe(
|
||||||
|
map((response: ResponseWrapper) => {
|
||||||
|
console.log("RESPONSE", response)
|
||||||
|
return response.data as ResourceFhir
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,147 +0,0 @@
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import {PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
|
||||||
import {User} from '../../lib/models/fasten/user';
|
|
||||||
import {ResponseWrapper} from '../models/response-wrapper';
|
|
||||||
import {HttpClient} from '@angular/common/http';
|
|
||||||
import {Summary} from '../../lib/models/fasten/summary';
|
|
||||||
import {DocType} from '../../lib/database/constants';
|
|
||||||
import {ResourceTypeCounts} from '../../lib/models/fasten/source-summary';
|
|
||||||
import {Base64} from '../../lib/utils/base64';
|
|
||||||
import * as jose from 'jose'
|
|
||||||
|
|
||||||
// PouchDB & plugins (must be similar to the plugins specified in pouchdb repository)
|
|
||||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
|
||||||
import find from 'pouchdb-find';
|
|
||||||
PouchDB.plugin(find); //does not work in
|
|
||||||
import * as PouchUpsert from 'pouchdb-upsert';
|
|
||||||
PouchDB.plugin(PouchUpsert);
|
|
||||||
import * as PouchCrypto from 'crypto-pouch';
|
|
||||||
PouchDB.plugin(PouchCrypto);
|
|
||||||
import PouchAuth from 'pouchdb-authentication'
|
|
||||||
import {PouchdbCrypto} from '../../lib/database/plugins/crypto';
|
|
||||||
import {environment} from '../../environments/environment';
|
|
||||||
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
|
|
||||||
import {AuthService} from './auth.service';
|
|
||||||
PouchDB.plugin(PouchAuth);
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class FastenDbService extends PouchdbRepository {
|
|
||||||
|
|
||||||
|
|
||||||
// There are 3 different ways to initialize the Database
|
|
||||||
// - explicitly after signin/signup
|
|
||||||
// - explicitly during web-worker init (not supported by this class, see PouchdbRepository.NewPouchdbRepositoryWebWorker)
|
|
||||||
// - implicitly after Lighthouse redirect (when user is directed back to the app)
|
|
||||||
// Three peices of information are required during intialization
|
|
||||||
// - couchdb endpoint (constant, see environment.couchdb_endpoint_base)
|
|
||||||
// - username
|
|
||||||
// - JWT token
|
|
||||||
constructor(private _httpClient: HttpClient, private authService: AuthService) {
|
|
||||||
super(environment.couchdb_endpoint_base);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to get PouchDB database using token auth information
|
|
||||||
* This method must handle 2 types of authentication
|
|
||||||
* - pouchdb init after signin/signup
|
|
||||||
* - implicit init after lighthouse redirect
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public override async GetSessionDB(): Promise<PouchDB.Database> {
|
|
||||||
if(this.pouchDb){
|
|
||||||
console.log("Session DB already exists..")
|
|
||||||
return this.pouchDb
|
|
||||||
}
|
|
||||||
|
|
||||||
//check if we have a JWT token (we should, otherwise the auth-guard would have redirected to login page)
|
|
||||||
let authToken = this.authService.GetAuthToken()
|
|
||||||
if(!authToken){
|
|
||||||
throw new Error("no auth token found")
|
|
||||||
}
|
|
||||||
|
|
||||||
//parse the authToken to get user information
|
|
||||||
this.current_user = this.authService.GetCurrentUser().sub
|
|
||||||
|
|
||||||
// add JWT bearer token header to all requests
|
|
||||||
// https://stackoverflow.com/questions/62129654/how-to-handle-jwt-authentication-with-rxdb
|
|
||||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(this.current_user), {
|
|
||||||
fetch: function (url, opts) {
|
|
||||||
opts.headers.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
return PouchDB.fetch(url, opts);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return this.pouchDb
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the crypto configuration for the authenticated user already available in the browser? Or do we need to import/generate new config.
|
|
||||||
*/
|
|
||||||
public async isCryptConfigAvailable(): Promise<boolean>{
|
|
||||||
try {
|
|
||||||
await this.GetSessionDB()
|
|
||||||
let cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
|
||||||
|
|
||||||
return !!cryptConfig
|
|
||||||
}catch(e){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public Close(): Promise<void> {
|
|
||||||
return super.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Summary
|
|
||||||
|
|
||||||
public async GetSummary(): Promise<Summary> {
|
|
||||||
const summary = new Summary()
|
|
||||||
summary.sources = await this.GetSources()
|
|
||||||
.then((paginatedResp) => paginatedResp.rows)
|
|
||||||
|
|
||||||
// summary.patients = []
|
|
||||||
summary.patients = await this.GetDB()
|
|
||||||
.then((db) => {
|
|
||||||
|
|
||||||
return db.find({
|
|
||||||
selector: {
|
|
||||||
doc_type: DocType.ResourceFhir,
|
|
||||||
source_resource_type: "Patient",
|
|
||||||
}
|
|
||||||
}).then((results) => {
|
|
||||||
return Promise.all((results.docs || []).map((doc) => PouchdbCrypto.decryptDocument(db, doc)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
summary.resource_type_counts = await this.findDocumentByPrefix(`${DocType.ResourceFhir}`, false)
|
|
||||||
.then((paginatedResp) => {
|
|
||||||
const lookup: {[name: string]: ResourceTypeCounts} = {}
|
|
||||||
paginatedResp?.rows.forEach((resourceWrapper) => {
|
|
||||||
const resourceIdParts = resourceWrapper.id.split(':')
|
|
||||||
const resourceType = resourceIdParts[2]
|
|
||||||
|
|
||||||
let currentResourceStats = lookup[resourceType] || {
|
|
||||||
count: 0,
|
|
||||||
source_id: Base64.Decode(resourceIdParts[1]),
|
|
||||||
resource_type: resourceType
|
|
||||||
}
|
|
||||||
currentResourceStats.count += 1
|
|
||||||
lookup[resourceType] = currentResourceStats
|
|
||||||
})
|
|
||||||
|
|
||||||
const arr = []
|
|
||||||
for(let key in lookup){
|
|
||||||
arr.push(lookup[key])
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@ import {Observable} from 'rxjs';
|
||||||
import {environment} from '../../environments/environment';
|
import {environment} from '../../environments/environment';
|
||||||
import {map, tap} from 'rxjs/operators';
|
import {map, tap} from 'rxjs/operators';
|
||||||
import {ResponseWrapper} from '../models/response-wrapper';
|
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 * as Oauth from '@panva/oauth4webapi';
|
||||||
import {SourceState} from '../models/fasten/source-state';
|
import {SourceState} from '../models/fasten/source-state';
|
||||||
import {MetadataSource} from '../models/fasten/metadata-source';
|
import {MetadataSource} from '../models/fasten/metadata-source';
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { QueueService } from './queue.service';
|
|
||||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
|
||||||
|
|
||||||
describe('QueueService', () => {
|
|
||||||
let service: QueueService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [HttpClientTestingModule],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(QueueService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,45 +0,0 @@
|
||||||
import {Injectable} from '@angular/core';
|
|
||||||
import {Observable, of} from 'rxjs';
|
|
||||||
import {fromWorker} from 'observable-webworker';
|
|
||||||
import {Source} from '../../lib/models/database/source';
|
|
||||||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
|
||||||
import {ToastService} from '../services/toast.service';
|
|
||||||
import {ToastNotification, ToastType} from '../models/fasten/toast';
|
|
||||||
import {FastenDbService} from '../services/fasten-db.service';
|
|
||||||
import {environment} from '../../environments/environment';
|
|
||||||
import {AuthService} from '../services/auth.service';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class QueueService {
|
|
||||||
|
|
||||||
constructor(private toastService: ToastService, private authService: AuthService) { }
|
|
||||||
|
|
||||||
runSourceSyncWorker(source: Source):Observable<string> {
|
|
||||||
if (typeof Worker !== 'undefined') {
|
|
||||||
const sourceSync = new SourceSyncMessage()
|
|
||||||
sourceSync.source = source
|
|
||||||
sourceSync.current_user = this.authService.GetCurrentUser().sub
|
|
||||||
sourceSync.auth_token = this.authService.GetAuthToken()
|
|
||||||
sourceSync.couchdb_endpoint_base = environment.couchdb_endpoint_base
|
|
||||||
sourceSync.fasten_api_endpoint_base = environment.fasten_api_endpoint_base
|
|
||||||
const input$: Observable<string> = of(JSON.stringify(sourceSync));
|
|
||||||
return fromWorker<string, string>(() => new Worker(new URL('./source-sync.worker', import.meta.url), {type: 'module'}), input$)
|
|
||||||
// .subscribe(message => {
|
|
||||||
// console.log(`Got message`, message);
|
|
||||||
// });
|
|
||||||
}else {
|
|
||||||
// Web workers are not supported in this environment.
|
|
||||||
// You should add a fallback so that your program still executes correctly.
|
|
||||||
console.error("WORKERS ARE NOT SUPPORTED")
|
|
||||||
|
|
||||||
const toastNotificaiton = new ToastNotification()
|
|
||||||
toastNotificaiton.type = ToastType.Error
|
|
||||||
toastNotificaiton.message = "Your browser does not support web-workers. Cannot continue."
|
|
||||||
toastNotificaiton.autohide = false
|
|
||||||
this.toastService.show(toastNotificaiton)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
/// <reference lib="webworker" />
|
|
||||||
|
|
||||||
import {DoWork, runWorker} from 'observable-webworker';
|
|
||||||
import {Observable} from 'rxjs';
|
|
||||||
import {mergeMap} from 'rxjs/operators';
|
|
||||||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
|
||||||
import {NewPouchdbRepositoryWebWorker, PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
|
||||||
import {NewClient} from '../../lib/conduit/factory';
|
|
||||||
import {Source} from '../../lib/models/database/source';
|
|
||||||
import {ClientConfig} from '../../lib/models/client/client-config';
|
|
||||||
import {client} from 'fhirclient';
|
|
||||||
|
|
||||||
export class SourceSyncWorker implements DoWork<string, string> {
|
|
||||||
public work(input$: Observable<string>): Observable<string> {
|
|
||||||
return input$.pipe(
|
|
||||||
//mergeMap allows us to convert a promise into an observable
|
|
||||||
// https://stackoverflow.com/questions/53649294/how-to-handle-for-promise-inside-a-piped-map
|
|
||||||
mergeMap(msg => {
|
|
||||||
try {
|
|
||||||
console.log(msg); // outputs 'Hello from main thread'
|
|
||||||
const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
|
|
||||||
|
|
||||||
const db = NewPouchdbRepositoryWebWorker({current_user: sourceSyncMessage.current_user, auth_token: sourceSyncMessage.auth_token}, sourceSyncMessage.couchdb_endpoint_base)
|
|
||||||
|
|
||||||
let clientConfig = new ClientConfig()
|
|
||||||
clientConfig.fasten_api_endpoint_base = sourceSyncMessage.fasten_api_endpoint_base
|
|
||||||
const client = NewClient(sourceSyncMessage.source.source_type, new Source(sourceSyncMessage.source), clientConfig)
|
|
||||||
//TODO: validate the FHIR version from the datasource matches the client
|
|
||||||
// if the source token has been refreshed, we need to store it in the DB.
|
|
||||||
// await db.UpsertSource()
|
|
||||||
|
|
||||||
|
|
||||||
//lets refresh the source information if required.
|
|
||||||
console.log("!!!!!!!!!!!!!!STARTING WORKER SYNC!!!!!!!!!", sourceSyncMessage)
|
|
||||||
return client.RefreshSourceToken()
|
|
||||||
.then((wasSourceRefreshed) => {
|
|
||||||
if(wasSourceRefreshed){
|
|
||||||
//the source was updated, we need to save the updated source information
|
|
||||||
return db.UpsertSource(client.source)
|
|
||||||
.then(() => {
|
|
||||||
return client
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return client
|
|
||||||
})
|
|
||||||
.then((client) => {
|
|
||||||
return client.SyncAll(db)
|
|
||||||
})
|
|
||||||
.then((resp) => {
|
|
||||||
console.log("!!!!!!!!!!!!!COMPLETE WORKER SYNC!!!!!!!!!!", resp)
|
|
||||||
sourceSyncMessage.response = resp
|
|
||||||
return JSON.stringify(sourceSyncMessage)
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("!!!!!!!!!!!!!ERROR WORKER SYNC!!!!!!!!!!", err)
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
// return from(resp)
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.log("CAUGHT ERROR", e)
|
|
||||||
console.trace(e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runWorker(SourceSyncWorker);
|
|
|
@ -1,84 +0,0 @@
|
||||||
import {SourceType} from '../models/database/source_types';
|
|
||||||
import {Source} from '../models/database/source';
|
|
||||||
import {IClient} from './interface';
|
|
||||||
|
|
||||||
import {HealthITClient} from './fhir/sandbox/healthit_client';
|
|
||||||
import {LogicaClient} from './fhir/sandbox/logica_client';
|
|
||||||
import {AthenaClient} from './fhir/sandbox/athena_client';
|
|
||||||
|
|
||||||
import {CareEvolutionClient} from './fhir/platforms/careevolution_client';
|
|
||||||
import {CernerClient} from './fhir/platforms/cerner_client';
|
|
||||||
import {EpicClient} from './fhir/platforms/epic_client';
|
|
||||||
|
|
||||||
import {AetnaClient} from './fhir/aetna_client';
|
|
||||||
import {BlueButtonClient} from './fhir/bluebutton_client';
|
|
||||||
import {CignaClient} from './fhir/cigna_client';
|
|
||||||
import {ClientConfig} from '../models/client/client-config';
|
|
||||||
|
|
||||||
export function NewClient(sourceType: SourceType, source: Source, clientConfig: ClientConfig): IClient {
|
|
||||||
|
|
||||||
switch(sourceType) {
|
|
||||||
|
|
||||||
//sandbox
|
|
||||||
case SourceType.Athena:
|
|
||||||
return new AthenaClient(source, clientConfig)
|
|
||||||
case SourceType.HealthIT:
|
|
||||||
return new HealthITClient(source, clientConfig)
|
|
||||||
case SourceType.Logica:
|
|
||||||
return new LogicaClient(source, clientConfig )
|
|
||||||
|
|
||||||
//platforms
|
|
||||||
case SourceType.CareEvolution:
|
|
||||||
return new CareEvolutionClient(source, clientConfig)
|
|
||||||
case SourceType.Cerner:
|
|
||||||
return new CernerClient(source, clientConfig)
|
|
||||||
case SourceType.Epic:
|
|
||||||
return new EpicClient(source, clientConfig)
|
|
||||||
|
|
||||||
//providers
|
|
||||||
case SourceType.Aetna:
|
|
||||||
return new AetnaClient(source, clientConfig)
|
|
||||||
|
|
||||||
case SourceType.Amerigroup:
|
|
||||||
case SourceType.AmerigroupMedicaid:
|
|
||||||
case SourceType.Anthem:
|
|
||||||
case SourceType.AnthemBluecrossCA:
|
|
||||||
case SourceType.BluecrossBlueshieldKansasMedicare:
|
|
||||||
case SourceType.BluecrossBlueshieldKansas:
|
|
||||||
case SourceType.BluecrossBlueshieldNY:
|
|
||||||
case SourceType.BlueMedicareAdvantage:
|
|
||||||
case SourceType.ClearHealthAlliance:
|
|
||||||
case SourceType.DellChildrens:
|
|
||||||
case SourceType.EmpireBlue:
|
|
||||||
case SourceType.EmpireBlueMedicaid:
|
|
||||||
case SourceType.HealthyBlueLA:
|
|
||||||
case SourceType.HealthyBlueLAMedicaid:
|
|
||||||
case SourceType.HealthyBlueMO:
|
|
||||||
case SourceType.HealthyBlueMOMedicaid:
|
|
||||||
case SourceType.HealthyBlueNC:
|
|
||||||
case SourceType.HealthyBlueNCMedicaid:
|
|
||||||
case SourceType.HealthyBlueNE:
|
|
||||||
case SourceType.HealthyBlueSC:
|
|
||||||
case SourceType.HighmarkWesternNY:
|
|
||||||
case SourceType.SimplyHealthcareMedicaid:
|
|
||||||
case SourceType.SimplyHealthcareMedicare:
|
|
||||||
case SourceType.SummitCommunityCare:
|
|
||||||
case SourceType.Unicare:
|
|
||||||
case SourceType.UnicareMA:
|
|
||||||
case SourceType.UnicareMedicaid:
|
|
||||||
return new CareEvolutionClient(source, clientConfig)
|
|
||||||
|
|
||||||
case SourceType.UCSF:
|
|
||||||
return new EpicClient(source, clientConfig)
|
|
||||||
|
|
||||||
case SourceType.Cigna:
|
|
||||||
return new CignaClient(source, clientConfig)
|
|
||||||
case SourceType.BlueButton:
|
|
||||||
return new BlueButtonClient(source, clientConfig)
|
|
||||||
|
|
||||||
// case SourceType.Manual:
|
|
||||||
// return new ManualClient(source)
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown Source Type: ${sourceType}`)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import {IClient} from '../interface';
|
|
||||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../models/client/client-config';
|
|
||||||
|
|
||||||
export class AetnaClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aetna overrides the SyncAll function because Patient-everything operation uses a non-standard endpoint
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const bundle = await this.GetResourceBundlePaginated("Patient")
|
|
||||||
|
|
||||||
const wrappedResourceModels = await this.ProcessBundle(bundle)
|
|
||||||
//todo, create the resources in dependency order
|
|
||||||
return await db.UpsertResources(wrappedResourceModels)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
import {BaseClient} from './base_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import * as BaseClient_GetRequest from './fixtures/BaseClient_GetRequest.json';
|
|
||||||
// @ts-ignore
|
|
||||||
import * as BaseClient_GetFhirVersion from './fixtures/BaseClient_GetFhirVersion.json';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
|
|
||||||
class TestClient extends BaseClient {
|
|
||||||
constructor(source: Source) {
|
|
||||||
super(source, new ClientConfig());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BaseClient', () => {
|
|
||||||
let client: TestClient;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
client = new TestClient(new Source({
|
|
||||||
"authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
|
|
||||||
"token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
|
|
||||||
"introspection_endpoint": "",
|
|
||||||
"issuer": "https://fhirsandbox.healthit.go",
|
|
||||||
"api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
|
|
||||||
"client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
|
|
||||||
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
|
|
||||||
"confidential": false,
|
|
||||||
"source_type": "healthit",
|
|
||||||
"patient": "placeholder",
|
|
||||||
"access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
|
|
||||||
"refresh_token": "",
|
|
||||||
"expires_at": 16649837100, //aug 11, 2497 (for testing)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(client).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GetRequest', () => {
|
|
||||||
it('should make an authorized request', async () => {
|
|
||||||
|
|
||||||
//setup
|
|
||||||
let response = new Response(JSON.stringify(BaseClient_GetRequest));
|
|
||||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}`});
|
|
||||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
|
||||||
|
|
||||||
//test
|
|
||||||
const resp = await client.GetRequest(`Patient/${client.source.patient}`)
|
|
||||||
|
|
||||||
//expect
|
|
||||||
expect(resp.resourceType).toEqual("Patient");
|
|
||||||
expect(resp.id).toEqual("123d41e1-0f71-4e9f-8eb2-d1b1330201a6");
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GetFhirVersion', () => {
|
|
||||||
it('should make an authorized request', async () => {
|
|
||||||
//setup
|
|
||||||
let response = new Response(JSON.stringify(BaseClient_GetFhirVersion));
|
|
||||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/metadata`});
|
|
||||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
|
||||||
|
|
||||||
//test
|
|
||||||
const resp = await client.GetFhirVersion()
|
|
||||||
|
|
||||||
//expect
|
|
||||||
expect(resp).toEqual("4.0.1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
|
@ -1,153 +0,0 @@
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import * as Oauth from '@panva/oauth4webapi';
|
|
||||||
import {IResourceRaw} from '../../interface';
|
|
||||||
import {GetEndpointAbsolutePath} from '../../../utils/endpoint_absolute_path';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
|
|
||||||
class SourceUpdateStatus {
|
|
||||||
is_updated: boolean = false
|
|
||||||
source: Source
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseClient is an abstract/partial class, its intended to be used by FHIR clients, and generically handle OAuth requests.
|
|
||||||
export abstract class BaseClient {
|
|
||||||
|
|
||||||
private clientConfig: ClientConfig
|
|
||||||
private oauthClient: Oauth.Client
|
|
||||||
private oauthAuthorizationServer: Oauth.AuthorizationServer
|
|
||||||
public source: Source
|
|
||||||
public headers: Headers
|
|
||||||
public fhirVersion: string
|
|
||||||
|
|
||||||
|
|
||||||
protected constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
this.source = source
|
|
||||||
this.clientConfig = clientConfig
|
|
||||||
this.headers = new Headers()
|
|
||||||
|
|
||||||
//init Oauth client based on source configuration
|
|
||||||
this.oauthClient = {
|
|
||||||
client_id: source.client_id,
|
|
||||||
client_secret: "placeholder" //this is always a placeholder, if client_secret is required (for confidential clients), token_endpoint will be Lighthouse server.
|
|
||||||
}
|
|
||||||
this.oauthAuthorizationServer = {
|
|
||||||
issuer: source.issuer,
|
|
||||||
authorization_endpoint: source.authorization_endpoint,
|
|
||||||
token_endpoint: source.token_endpoint,
|
|
||||||
introspection_endpoint: source.introspection_endpoint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function gets the FhirVersion as specified by the api CapabilityStatement endpoint (metadata)
|
|
||||||
* https://build.fhir.org/capabilitystatement.html
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async GetFhirVersion(): Promise<any> {
|
|
||||||
return this.GetRequest("metadata")
|
|
||||||
.then((resp) => {
|
|
||||||
return resp.fhirVersion
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function will make an authenticated request against an OAuth protected resource. If the AccessToken used has expired, it will attempt
|
|
||||||
* to use a refresh token (if present) to get a new AccessToken.
|
|
||||||
* @param resourceSubpathOrNext
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async GetRequest(resourceSubpathOrNext: string): Promise<any> {
|
|
||||||
|
|
||||||
//check if the url is absolute
|
|
||||||
let resourceUrl: string
|
|
||||||
if (!(resourceSubpathOrNext.indexOf('http://') === 0 || resourceSubpathOrNext.indexOf('https://') === 0)) {
|
|
||||||
//not absolute, so lets prefix with the source api endpoint
|
|
||||||
resourceUrl = `${this.source.api_endpoint_base_url.trimEnd()}/${resourceSubpathOrNext.trimStart()}`
|
|
||||||
} else {
|
|
||||||
resourceUrl = resourceSubpathOrNext
|
|
||||||
}
|
|
||||||
if(this.source.cors_relay_required){
|
|
||||||
//this endpoint requires a CORS relay
|
|
||||||
//get the path to the Fasten server, and append `cors/` and then append the request url
|
|
||||||
let resourceParts = new URL(resourceUrl)
|
|
||||||
resourceUrl = GetEndpointAbsolutePath(globalThis.location, this.clientConfig.fasten_api_endpoint_base) + `/cors/${resourceParts.hostname}${resourceParts.pathname}${resourceParts.search}`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//refresh the source if required
|
|
||||||
await this.RefreshSourceToken()
|
|
||||||
|
|
||||||
//make a request to the protected resource
|
|
||||||
const resp = await Oauth.protectedResourceRequest(this.source.access_token, 'GET', new URL(resourceUrl), this.headers, null)
|
|
||||||
|
|
||||||
if(resp.status >=300 || resp.status < 200){
|
|
||||||
// b, _ := io.ReadAll(resp.Body)
|
|
||||||
throw new Error(`An error occurred during request ${resourceUrl} - ${resp.status} - ${resp.statusText} ${await resp.text()}`)
|
|
||||||
}
|
|
||||||
return resp.json()
|
|
||||||
// err = ParseBundle(resp.Body, decodeModelPtr)
|
|
||||||
// return err
|
|
||||||
}
|
|
||||||
|
|
||||||
public async RefreshSourceToken(): Promise<boolean>{
|
|
||||||
|
|
||||||
let sourceUpdateStatus = await this.refreshExpiredTokenIfRequired(this.source)
|
|
||||||
this.source = sourceUpdateStatus.source
|
|
||||||
return sourceUpdateStatus.is_updated
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Protected methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Private methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
private async refreshExpiredTokenIfRequired(source: Source): Promise<SourceUpdateStatus> {
|
|
||||||
const sourceUpdateStatus = new SourceUpdateStatus()
|
|
||||||
//check if token has expired, and a refreshtoken is available
|
|
||||||
// Note: source.expires_at is in seconds, Date.now() is in milliseconds.
|
|
||||||
if(source.expires_at > Math.floor(Date.now() / 1000)) { //not expired return
|
|
||||||
sourceUpdateStatus.source = source
|
|
||||||
return Promise.resolve(sourceUpdateStatus)
|
|
||||||
}
|
|
||||||
if(!source.refresh_token){
|
|
||||||
return Promise.reject(new Error("access token is expired, but no refresh token available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("access token expired, refreshing...")
|
|
||||||
return Oauth.refreshTokenGrantRequest(this.oauthAuthorizationServer, this.oauthClient, source.refresh_token)
|
|
||||||
.then((refreshTokenResp) => {
|
|
||||||
return Oauth.processRefreshTokenResponse(this.oauthAuthorizationServer, this.oauthClient, refreshTokenResp)
|
|
||||||
})
|
|
||||||
.then((newToken) => {
|
|
||||||
|
|
||||||
if(newToken.access_token != source.access_token){
|
|
||||||
sourceUpdateStatus.is_updated = true
|
|
||||||
// {
|
|
||||||
// access_token: 'token',
|
|
||||||
// token_type: 'bearer',
|
|
||||||
// expires_in: 60,
|
|
||||||
// scope: 'api:read',
|
|
||||||
// refresh_token: 'refresh_token',
|
|
||||||
// }
|
|
||||||
|
|
||||||
source.access_token = newToken.access_token as string
|
|
||||||
// @ts-ignore
|
|
||||||
source.expires_at = Math.floor(Date.now() / 1000) + parseInt(newToken.expires_in);
|
|
||||||
|
|
||||||
// update the "source" credential with new data (which will need to be sent
|
|
||||||
// Don't overwrite `RefreshToken` with an empty value
|
|
||||||
if(newToken.refresh_token != ""){
|
|
||||||
source.refresh_token = newToken.refresh_token as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceUpdateStatus.source = source
|
|
||||||
return sourceUpdateStatus
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,119 +0,0 @@
|
||||||
import {FHIR401Client} from './fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IResourceBundleRaw} from '../../interface';
|
|
||||||
import {ResourceFhir} from '../../../models/database/resource_fhir';
|
|
||||||
import {NewPouchdbRepositoryWebWorker} from '../../../database/pouchdb_repository';
|
|
||||||
import {Base64} from '../../../utils/base64';
|
|
||||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json';
|
|
||||||
// @ts-ignore
|
|
||||||
import * as FHIR401Client_ExtractResourceReference from './fixtures/FHIR401Client_ExtractResourceReference.json';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {PouchdbCrypto} from '../../../database/plugins/crypto';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
|
|
||||||
class TestClient extends FHIR401Client {
|
|
||||||
constructor(source: Source) {
|
|
||||||
super(source, new ClientConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
|
|
||||||
return super.ProcessBundle(bundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FHIR401Client', () => {
|
|
||||||
let client: TestClient;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
client = new TestClient(new Source({
|
|
||||||
"_id": "source:aetna:12345",
|
|
||||||
"authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
|
|
||||||
"token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
|
|
||||||
"introspection_endpoint": "",
|
|
||||||
"issuer": "https://fhirsandbox.healthit.go",
|
|
||||||
"api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
|
|
||||||
"client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
|
|
||||||
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
|
|
||||||
"confidential": false,
|
|
||||||
"source_type": "healthit",
|
|
||||||
"patient": "placeholder",
|
|
||||||
"access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
|
|
||||||
"refresh_token": "",
|
|
||||||
"expires_at": 16649837100, //aug 11, 2497 (for testing)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(client).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ProcessBundle', () => {
|
|
||||||
it('should correctly wrap each BundleEntry with ResourceFhir', async () => {
|
|
||||||
|
|
||||||
//setup
|
|
||||||
|
|
||||||
//test
|
|
||||||
const resp = await client.ProcessBundle(FHIR401Client_ProcessBundle)
|
|
||||||
//expect
|
|
||||||
expect(resp.length).toEqual(206);
|
|
||||||
expect(resp[0].source_resource_id).toEqual("c088b7af-fc41-43cc-ab80-4a9ab8d47cd9");
|
|
||||||
expect(resp[0].source_resource_type).toEqual("Patient");
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SyncAll', () => {
|
|
||||||
let repository: IDatabaseRepository;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
let current_user = uuidv4() as string
|
|
||||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
|
||||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
|
||||||
repository = NewPouchdbRepositoryWebWorker({current_user: current_user, auth_token:""}, '/database', new PouchDB("FHIR401Client-"+ current_user));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if(repository){
|
|
||||||
const db = await repository.GetDB()
|
|
||||||
db.destroy() //wipe the db.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('should correctly add resources to the database', async () => {
|
|
||||||
//setup
|
|
||||||
let response = new Response(JSON.stringify(FHIR401Client_ProcessBundle));
|
|
||||||
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}/$everything`});
|
|
||||||
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
|
|
||||||
|
|
||||||
//test
|
|
||||||
const resp = await client.SyncAll(repository)
|
|
||||||
const firstResourceFhir = resp.updatedResources[0]
|
|
||||||
const resourceIdParts = firstResourceFhir.split(":")
|
|
||||||
|
|
||||||
//expect
|
|
||||||
expect(resp.totalResources).toEqual(206);
|
|
||||||
expect(resp.updatedResources.length).toEqual(206);
|
|
||||||
expect(firstResourceFhir).toEqual('resource_fhir:b64.c291cmNlOmFldG5hOjEyMzQ1:Patient:c088b7af-fc41-43cc-ab80-4a9ab8d47cd9');
|
|
||||||
expect(Base64.Decode(resourceIdParts[1])).toEqual("source:aetna:12345");
|
|
||||||
|
|
||||||
|
|
||||||
}, 10000);
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ExtractResourceReference', () => {
|
|
||||||
it('should correctly extract resource identifier from raw resources', async () => {
|
|
||||||
|
|
||||||
//setup
|
|
||||||
|
|
||||||
//test
|
|
||||||
const resp = await client.ExtractResourceReference("CarePlan", FHIR401Client_ExtractResourceReference)
|
|
||||||
//expect
|
|
||||||
expect(resp.length).toEqual(2);
|
|
||||||
expect(resp).toEqual(['Encounter/97961321', 'Practitioner/12763770']);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
|
@ -1,594 +0,0 @@
|
||||||
import {IClient, IResourceBundleEntryRaw, IResourceBundleRaw, IResourceRaw} from '../../interface';
|
|
||||||
import {BaseClient} from './base_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {ResourceFhir} from '../../../models/database/resource_fhir';
|
|
||||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class FHIR401Client extends BaseClient implements IClient {
|
|
||||||
|
|
||||||
//clients extending this class must validate fhirVersion matches using conformance/metadata url.
|
|
||||||
fhirVersion = "4.0.1"
|
|
||||||
|
|
||||||
// https://build.fhir.org/ig/HL7/US-Core/
|
|
||||||
usCoreResources: string[] = [
|
|
||||||
"AllergyIntolerance",
|
|
||||||
//"Binary",
|
|
||||||
"CarePlan",
|
|
||||||
"CareTeam",
|
|
||||||
"Condition",
|
|
||||||
//"Coverage",
|
|
||||||
"Device",
|
|
||||||
"DiagnosticReport",
|
|
||||||
"DocumentReference",
|
|
||||||
"Encounter",
|
|
||||||
"Goal",
|
|
||||||
"Immunization",
|
|
||||||
//"Location",
|
|
||||||
//"Medication",
|
|
||||||
//"MedicationRequest",
|
|
||||||
"Observation",
|
|
||||||
//"Organization",
|
|
||||||
//"Patient",
|
|
||||||
//"Practitioner",
|
|
||||||
//"PractitionerRole",
|
|
||||||
"Procedure",
|
|
||||||
//"Provenance",
|
|
||||||
//"RelatedPerson",
|
|
||||||
// "ServiceRequest",
|
|
||||||
// "Specimen",
|
|
||||||
]
|
|
||||||
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function attempts to retrieve a Patient Bundle and sync all resources to the database
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const bundle = await this.GetPatientBundle(this.source.patient)
|
|
||||||
|
|
||||||
const wrappedResourceModels = await this.ProcessBundle(bundle)
|
|
||||||
//todo, create the resources in dependency order
|
|
||||||
|
|
||||||
return db.UpsertResources(wrappedResourceModels)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If Patient-everything api endpoint is unavailable (SyncAll) this function can be used to search for each resource associated with a Patient
|
|
||||||
* and sync them to the database.
|
|
||||||
* @param db
|
|
||||||
* @param resourceNames
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>{
|
|
||||||
//Store the Patient
|
|
||||||
const patientResource = await this.GetPatient(this.source.patient)
|
|
||||||
|
|
||||||
const patientResourceFhir = new ResourceFhir()
|
|
||||||
patientResourceFhir.source_id = this.source._id
|
|
||||||
patientResourceFhir.source_resource_type = patientResource.resourceType
|
|
||||||
patientResourceFhir.source_resource_id = patientResource.id
|
|
||||||
patientResourceFhir.resource_raw = patientResource
|
|
||||||
|
|
||||||
const upsertSummary = await db.UpsertResource(patientResourceFhir)
|
|
||||||
|
|
||||||
//error map storage.
|
|
||||||
let syncErrors = {}
|
|
||||||
|
|
||||||
//Store all other resources.
|
|
||||||
for(let resourceType of resourceNames) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
let bundle = await this.GetResourceBundlePaginated(`${resourceType}?patient=${this.source.patient}`)
|
|
||||||
let wrappedResourceModels = await this.ProcessBundle(bundle)
|
|
||||||
let resourceUpsertSummary = await db.UpsertResources(wrappedResourceModels)
|
|
||||||
|
|
||||||
upsertSummary.updatedResources = upsertSummary.updatedResources.concat(resourceUpsertSummary.updatedResources)
|
|
||||||
upsertSummary.totalResources += resourceUpsertSummary.totalResources
|
|
||||||
|
|
||||||
|
|
||||||
// check if theres any "extracted" resource references that we should sync as well
|
|
||||||
let extractedResourceReferences = []
|
|
||||||
extractedResourceReferences = wrappedResourceModels.reduce((previousVal, wrappedResource) => {
|
|
||||||
return previousVal.concat(this.ExtractResourceReference(wrappedResource.source_resource_type, wrappedResource.resource_raw))
|
|
||||||
}, extractedResourceReferences)
|
|
||||||
|
|
||||||
|
|
||||||
if(extractedResourceReferences.length > 0 ){
|
|
||||||
console.log("Extracted Resource References", extractedResourceReferences)
|
|
||||||
|
|
||||||
let extractedResourceBundle = await this.GenerateResourceBundleFromResourceIds(extractedResourceReferences)
|
|
||||||
let wrappedExtractedResourceBundle = await this.ProcessBundle(extractedResourceBundle)
|
|
||||||
let extractedResourceUpsertSummary = await db.UpsertResources(wrappedExtractedResourceBundle)
|
|
||||||
|
|
||||||
upsertSummary.updatedResources = upsertSummary.updatedResources.concat(extractedResourceUpsertSummary.updatedResources)
|
|
||||||
upsertSummary.totalResources += extractedResourceUpsertSummary.totalResources
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error(`An error occurred while processing ${resourceType} bundle ${this.source.patient}`)
|
|
||||||
syncErrors[resourceType] = e
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: correctly return newly inserted documents
|
|
||||||
return upsertSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a raw resource payload, this function will determine if there are references to other resources, and extract them if so
|
|
||||||
* This is useful because search (by patient id) is disabled for certain resource types.
|
|
||||||
* @param sourceResourceType
|
|
||||||
* @param resourceRaw
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public ExtractResourceReference(sourceResourceType: string, resourceRaw): string[] {
|
|
||||||
let resourceRefs = []
|
|
||||||
|
|
||||||
switch (sourceResourceType) {
|
|
||||||
case "CarePlan":
|
|
||||||
// encounter can contain
|
|
||||||
//- Encounter
|
|
||||||
resourceRefs.push(resourceRaw.encounter?.reference)
|
|
||||||
|
|
||||||
//author can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- CareTeam
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRefs.push(resourceRaw.author?.reference)
|
|
||||||
|
|
||||||
|
|
||||||
//contributor can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- CareTeam
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRefs.push(resourceRaw.contributor?.reference)
|
|
||||||
|
|
||||||
//careTeam can contain
|
|
||||||
//- CareTeam
|
|
||||||
resourceRefs.push(resourceRaw.careTeam?.reference)
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "CareTeam":
|
|
||||||
// encounter can contain
|
|
||||||
//- Encounter
|
|
||||||
resourceRefs.push(resourceRaw.encounter?.reference)
|
|
||||||
|
|
||||||
//participant[x].member can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- CareTeam
|
|
||||||
//- RelatedPerson
|
|
||||||
//participant[x].onBehalfOf can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRaw.participant?.map((participant) => {
|
|
||||||
resourceRefs.push(participant.member?.reference)
|
|
||||||
resourceRefs.push(participant.onBehalfOf?.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//managingOrganization
|
|
||||||
//- Organization
|
|
||||||
resourceRaw.managingOrganization?.map((managingOrganization) => {
|
|
||||||
resourceRefs.push(managingOrganization.reference)
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
case "Condition":
|
|
||||||
// recorder can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- PractitionerRole
|
|
||||||
//- Patient
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRefs.push(resourceRaw.recorder?.reference)
|
|
||||||
|
|
||||||
// asserter can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- PractitionerRole
|
|
||||||
//- Patient
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRefs.push(resourceRaw.asserter?.reference)
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "DiagnosticReport":
|
|
||||||
//basedOn[x] can contain
|
|
||||||
//- CarePlan
|
|
||||||
//- ImmunizationRecommendation
|
|
||||||
//- MedicationRequest
|
|
||||||
//- NutritionOrder
|
|
||||||
//- ServiceRequest
|
|
||||||
resourceRaw.basedOn?.map((basedOn) => {
|
|
||||||
resourceRefs.push(basedOn.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
// performer[x] can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- PractitionerRole
|
|
||||||
//- Organization
|
|
||||||
//- CareTeam
|
|
||||||
resourceRaw.performer?.map((performer) => {
|
|
||||||
resourceRefs.push(performer.reference)
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
case "DocumentReference":
|
|
||||||
//author[x] can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- CareTeam
|
|
||||||
//- Device
|
|
||||||
resourceRaw.author?.map((author) => {
|
|
||||||
resourceRefs.push(author.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//authenticator can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- PractitionerRole
|
|
||||||
resourceRefs.push(resourceRaw.authenticator?.reference)
|
|
||||||
|
|
||||||
// custodian can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.custodian?.reference)
|
|
||||||
|
|
||||||
// relatesTo.target
|
|
||||||
//- DocumentReference
|
|
||||||
resourceRaw.relatesTo?.map((relatesTo) => {
|
|
||||||
resourceRefs.push(relatesTo.target?.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//content.attachment can contain
|
|
||||||
//- Attachment
|
|
||||||
break
|
|
||||||
case "Encounter":
|
|
||||||
// basedOn[x] can contain
|
|
||||||
//- ServiceRequest
|
|
||||||
resourceRaw.basedOn?.map((basedOn) => {
|
|
||||||
resourceRefs.push(basedOn.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//participant[x].individual can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- PractitionerRole
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRaw.participant?.map((participant) => {
|
|
||||||
resourceRefs.push(participant.individual?.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//reasonReference[x] can contain
|
|
||||||
//- Condition
|
|
||||||
//- Procedure
|
|
||||||
//- Observation
|
|
||||||
//- ImmunizationRecommendation
|
|
||||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
|
||||||
resourceRefs.push(reasonReference.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//hospitalization.origin can contain
|
|
||||||
//- Location
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.hospitalization?.origin?.reference)
|
|
||||||
|
|
||||||
//hospitalization.destination can contain
|
|
||||||
//- Location
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.hospitalization?.destination?.reference)
|
|
||||||
|
|
||||||
//location[x].location can contain
|
|
||||||
//- Location
|
|
||||||
resourceRaw.location?.map((location) => {
|
|
||||||
resourceRefs.push(location.location?.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//serviceProvider can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.serviceProvider?.reference)
|
|
||||||
|
|
||||||
break
|
|
||||||
case "Immunization":
|
|
||||||
// location can contain
|
|
||||||
//- Location
|
|
||||||
resourceRefs.push(resourceRaw.location?.reference)
|
|
||||||
|
|
||||||
// manufacturer can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.manufacturer?.reference)
|
|
||||||
|
|
||||||
//performer[x].actor can contain
|
|
||||||
//- Practitioner | PractitionerRole | Organization
|
|
||||||
resourceRaw.performer?.map((performer) => {
|
|
||||||
resourceRefs.push(performer.actor?.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//reasonReference[x] can contain
|
|
||||||
//- Condition | Observation | DiagnosticReport
|
|
||||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
|
||||||
resourceRefs.push(reasonReference.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//protocolApplied[x].authority can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRaw.protocolApplied?.map((protocolApplied) => {
|
|
||||||
resourceRefs.push(protocolApplied.authority?.reference)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "Location":
|
|
||||||
// managingOrganization can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.managingOrganization?.reference)
|
|
||||||
|
|
||||||
// partOf can contain
|
|
||||||
//- Location
|
|
||||||
resourceRefs.push(resourceRaw.partOf?.reference)
|
|
||||||
|
|
||||||
break
|
|
||||||
case "MedicationRequest":
|
|
||||||
// reported.reportedReference can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- RelatedPerson
|
|
||||||
resourceRefs.push(resourceRaw.reported?.reportedReference?.reference)
|
|
||||||
|
|
||||||
// medication[x] can contain
|
|
||||||
//- Medication
|
|
||||||
resourceRefs.push(resourceRaw.reported?.reportedReference?.reference)
|
|
||||||
|
|
||||||
// requester can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- RelatedPerson
|
|
||||||
//- Device
|
|
||||||
resourceRefs.push(resourceRaw.requester?.reference)
|
|
||||||
|
|
||||||
// performer can contain
|
|
||||||
//- Practitioner | PractitionerRole | Organization | Patient | Device | RelatedPerson | CareTeam
|
|
||||||
resourceRefs.push(resourceRaw.performer?.reference)
|
|
||||||
// recorder can contain
|
|
||||||
//- Practitioner | PractitionerRole
|
|
||||||
resourceRefs.push(resourceRaw.recorder?.reference)
|
|
||||||
|
|
||||||
//TODO: reasonReference
|
|
||||||
//TODO: basedOn
|
|
||||||
//TODO: insurance
|
|
||||||
|
|
||||||
// dispenseRequest.performer can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.dispenseRequest?.performer?.reference)
|
|
||||||
break
|
|
||||||
case "Observation":
|
|
||||||
//basedOn[x] can contain
|
|
||||||
//- CarePlan | DeviceRequest | ImmunizationRecommendation | MedicationRequest | NutritionOrder | ServiceRequest
|
|
||||||
resourceRaw.basedOn?.map((basedOn) => {
|
|
||||||
resourceRefs.push(basedOn.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
// partOf[x] can contain
|
|
||||||
//- MedicationAdministration | MedicationDispense | MedicationStatement | Procedure | Immunization | ImagingStudy
|
|
||||||
resourceRaw.partOf?.map((partOf) => {
|
|
||||||
resourceRefs.push(partOf.reference)
|
|
||||||
})
|
|
||||||
// performer[x] can contain
|
|
||||||
//- Practitioner | PractitionerRole | Organization | CareTeam | Patient | RelatedPerson
|
|
||||||
resourceRaw.performer?.map((performer) => {
|
|
||||||
resourceRefs.push(performer.reference)
|
|
||||||
})
|
|
||||||
// device can contain
|
|
||||||
//- Device | DeviceMetric
|
|
||||||
resourceRefs.push(resourceRaw.device?.reference)
|
|
||||||
|
|
||||||
break
|
|
||||||
case "PractitionerRole":
|
|
||||||
// practitioner can contain
|
|
||||||
//- Practitioner
|
|
||||||
resourceRefs.push(resourceRaw.practitioner?.reference)
|
|
||||||
|
|
||||||
//organization can contain
|
|
||||||
//- Organization
|
|
||||||
resourceRefs.push(resourceRaw.organization?.reference)
|
|
||||||
|
|
||||||
//location can contain
|
|
||||||
//- Location
|
|
||||||
resourceRefs.push(resourceRaw.location?.reference)
|
|
||||||
|
|
||||||
//TODO: healthcareService
|
|
||||||
//TODO: endpoint
|
|
||||||
break
|
|
||||||
case "ServiceRequest":
|
|
||||||
// basedOn[x] can contain
|
|
||||||
//- CarePlan | ServiceRequest | MedicationRequest
|
|
||||||
resourceRaw.basedOn?.map((basedOn) => {
|
|
||||||
resourceRefs.push(basedOn.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//requester can contain
|
|
||||||
//- Practitioner
|
|
||||||
//- Organization
|
|
||||||
//- Patient
|
|
||||||
//- PractitionerRole
|
|
||||||
//- RelatedPerson
|
|
||||||
//- Device
|
|
||||||
resourceRefs.push(resourceRaw.requester?.reference)
|
|
||||||
|
|
||||||
//performer[x] can contain
|
|
||||||
//- Practitioner | PractitionerRole | Organization | CareTeam | HealthcareService | Patient | Device | RelatedPerson
|
|
||||||
resourceRaw.performer?.map((performer) => {
|
|
||||||
resourceRefs.push(performer.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//locationReference[x] an contain
|
|
||||||
//-Location
|
|
||||||
resourceRaw.locationReference?.map((locationReference) => {
|
|
||||||
resourceRefs.push(locationReference.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//reasonReference[x] can contain
|
|
||||||
//-Condition
|
|
||||||
//-Observation
|
|
||||||
//-DiagnosticReport
|
|
||||||
//-DocumentReference
|
|
||||||
resourceRaw.reasonReference?.map((reasonReference) => {
|
|
||||||
resourceRefs.push(reasonReference.reference)
|
|
||||||
})
|
|
||||||
|
|
||||||
//insurance[x] can contain
|
|
||||||
//- Coverage | ClaimResponse
|
|
||||||
resourceRaw.insurance?.map((insurance) => {
|
|
||||||
resourceRefs.push(insurance.reference)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove all null values, remove all duplicates
|
|
||||||
let cleanResourceRefs = resourceRefs.filter(i => !(typeof i === 'undefined' || i === null));
|
|
||||||
cleanResourceRefs = [...new Set(cleanResourceRefs)]
|
|
||||||
return cleanResourceRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is used to sync all resources from a Bundle file. Not applicable to this Client
|
|
||||||
* @param db
|
|
||||||
* @param bundleFile
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary> {
|
|
||||||
return Promise.reject(new Error("not implemented"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Protected methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
/**
|
|
||||||
* This function attempts to retrieve the Patient Bundle using the Patient-everything api endpoint (if available)
|
|
||||||
* This response may be paginated
|
|
||||||
* https://hl7.org/fhir/operation-patient-everything.html
|
|
||||||
* @param patientId
|
|
||||||
* @constructor
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected GetPatientBundle(patientId: string): Promise<any> {
|
|
||||||
return this.GetResourceBundlePaginated(`Patient/${patientId}/$everything`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function retrieves a patient resource
|
|
||||||
* @param patientId
|
|
||||||
* @constructor
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected GetPatient(patientId: string): Promise<IResourceRaw> {
|
|
||||||
return this.GetRequest(`Patient/${patientId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function parses a FHIR Bundle and wraps each BundleEntry resource in a ResourceFhir object which can be stored in the DB.
|
|
||||||
* @param bundle
|
|
||||||
* @constructor
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
|
|
||||||
// console.log(bundle)
|
|
||||||
// process each entry in bundle
|
|
||||||
return bundle.entry
|
|
||||||
.filter((bundleEntry) => {
|
|
||||||
return bundleEntry.resource.id // keep this entry if it has an ID, skip otherwise.
|
|
||||||
})
|
|
||||||
.map((bundleEntry) => {
|
|
||||||
const wrappedResourceModel = new ResourceFhir()
|
|
||||||
wrappedResourceModel.fhir_version = this.fhirVersion
|
|
||||||
wrappedResourceModel.source_id = this.source._id
|
|
||||||
wrappedResourceModel.source_resource_id = bundleEntry.resource.id
|
|
||||||
wrappedResourceModel.source_resource_type = bundleEntry.resource.resourceType
|
|
||||||
wrappedResourceModel.resource_raw = bundleEntry.resource
|
|
||||||
// TODO find a way to safely/consistently get the resource updated date (and other metadata) which shoudl be added to the model.
|
|
||||||
// wrappedResourceModel.updated_at = bundleEntry.resource.meta?.lastUpdated
|
|
||||||
return wrappedResourceModel
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a list of resource ids (Patient/xxx, Observation/yyyy), request these resources and generate a pseudo-bundle file
|
|
||||||
* @param resourceIds
|
|
||||||
* @constructor
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected async GenerateResourceBundleFromResourceIds(resourceIds: string[]): Promise<IResourceBundleRaw>{
|
|
||||||
|
|
||||||
resourceIds = [...new Set(resourceIds)] //make sure they are unique references.
|
|
||||||
let rawResourceRefs = await Promise.all(resourceIds.map(async (extractedResourceRef) => {
|
|
||||||
return {
|
|
||||||
resource: await this.GetRequest(extractedResourceRef) as IResourceRaw
|
|
||||||
} as IResourceBundleEntryRaw
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {resourceType: "Bundle", entry: rawResourceRefs}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a resource bundle. While "next" link is present in response, continue to request urls and append BundleEntries
|
|
||||||
* @param relativeResourcePath
|
|
||||||
* @constructor
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
protected async GetResourceBundlePaginated(relativeResourcePath: string): Promise<IResourceBundleRaw> {
|
|
||||||
// https://www.hl7.org/fhir/patient-operation-everything.html
|
|
||||||
|
|
||||||
const bundle = await this.GetRequest(relativeResourcePath) as IResourceBundleRaw
|
|
||||||
|
|
||||||
let next: string
|
|
||||||
let prev: string
|
|
||||||
let self: string
|
|
||||||
for(let link of bundle.link || []){
|
|
||||||
if(link.relation == "next"){
|
|
||||||
next = link.url
|
|
||||||
} else if(link.relation == "self"){
|
|
||||||
self = link.url
|
|
||||||
} else if(link.relation == "previous"){
|
|
||||||
prev = link.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while(next && next != self && next != prev){
|
|
||||||
console.debug(`Paginated request => ${next}`)
|
|
||||||
let nextBundle = await this.GetRequest(next) as IResourceBundleRaw
|
|
||||||
bundle.entry = bundle.entry.concat(nextBundle.entry)
|
|
||||||
|
|
||||||
next = "" //reset the pointers
|
|
||||||
self = ""
|
|
||||||
prev = ""
|
|
||||||
for(let link of nextBundle.link){
|
|
||||||
if(link.relation == "next"){
|
|
||||||
next = link.url
|
|
||||||
} else if(link.relation == "self"){
|
|
||||||
self = link.url
|
|
||||||
} else if(link.relation == "previous"){
|
|
||||||
prev = link.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Private methods
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,159 +0,0 @@
|
||||||
{
|
|
||||||
"resourceType": "Patient",
|
|
||||||
"id": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6",
|
|
||||||
"meta": {
|
|
||||||
"versionId": "1.0"
|
|
||||||
},
|
|
||||||
"extension": [
|
|
||||||
{
|
|
||||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race",
|
|
||||||
"extension": [
|
|
||||||
{
|
|
||||||
"url": "ombCategory",
|
|
||||||
"valueCoding": {
|
|
||||||
"system": "urn:oid:2.16.840.1.113883.6.238",
|
|
||||||
"code": "2054-5",
|
|
||||||
"display": "Black or African American"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "text",
|
|
||||||
"valueString": "Black or African American"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity",
|
|
||||||
"extension": [
|
|
||||||
{
|
|
||||||
"url": "ombCategory",
|
|
||||||
"valueCoding": {
|
|
||||||
"system": "urn:oid:2.16.840.1.113883.6.238",
|
|
||||||
"code": "2135-2",
|
|
||||||
"display": "Hispanic or Latino"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "text",
|
|
||||||
"valueString": "Hispanic or Latino"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName",
|
|
||||||
"valueString": "Ramona980 Franco581"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex",
|
|
||||||
"valueCode": "M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
|
|
||||||
"valueCode": "masked"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"identifier": [
|
|
||||||
{
|
|
||||||
"system": "https://github.com/synthetichealth/synthea",
|
|
||||||
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": {
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
|
||||||
"code": "MR",
|
|
||||||
"display": "Medical Record Number"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"system": "http://hospital.smarthealthit.org",
|
|
||||||
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": {
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
|
||||||
"code": "SS",
|
|
||||||
"display": "Social Security Number"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"system": "http://hl7.org/fhir/sid/us-ssn",
|
|
||||||
"value": "999-50-6731"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": {
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
|
|
||||||
"code": "DL",
|
|
||||||
"display": "Drivers License"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"system": "urn:oid:2.16.840.1.113883.4.3.25",
|
|
||||||
"value": "S99957478"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": [
|
|
||||||
{
|
|
||||||
"use": "official",
|
|
||||||
"family": "Gutiérrez115",
|
|
||||||
"given": [
|
|
||||||
"Hugo693"
|
|
||||||
],
|
|
||||||
"prefix": [
|
|
||||||
"Mr."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"telecom": [
|
|
||||||
{
|
|
||||||
"system": "phone",
|
|
||||||
"value": "555-914-6475",
|
|
||||||
"use": "home"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"gender": "male",
|
|
||||||
"birthDate": "1969-01-12",
|
|
||||||
"address": [
|
|
||||||
{
|
|
||||||
"line": [
|
|
||||||
"[\"293 Douglas Ramp\"]"
|
|
||||||
],
|
|
||||||
"city": "Natick",
|
|
||||||
"state": "MA",
|
|
||||||
"postalCode": "01999",
|
|
||||||
"country": "US",
|
|
||||||
"period": {
|
|
||||||
"start": "1969-01-12T00:00:00+00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"maritalStatus": {
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus",
|
|
||||||
"code": "M",
|
|
||||||
"display": "M"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"text": "M"
|
|
||||||
},
|
|
||||||
"multipleBirthBoolean": false,
|
|
||||||
"communication": [
|
|
||||||
{
|
|
||||||
"language": {
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "urn:ietf:bcp:47",
|
|
||||||
"code": "es",
|
|
||||||
"display": "Spanish"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"resourceType": "CarePlan",
|
|
||||||
"id": "197543976",
|
|
||||||
"meta": {
|
|
||||||
"versionId": "1",
|
|
||||||
"lastUpdated": "2021-10-04T14:46:33.000Z"
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"status": "additional",
|
|
||||||
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Care Plan</b></p><p><b>Patient</b>: SMART, NANCYU NHA EEEEEE</p><p><b>Title</b>: Comorbidities found via Retrieve Dx</p><p><b>Description</b>: Malnutrition:Severe --- Inputs [Height: 165.1, Weight: 25, BMI (Calculated): 9.171615976368047]\n\nAnoxic Brain Injury\n\n</p><p><b>Status</b>: Active</p><p><b>Intent</b>: Plan</p><p><b>Category</b>: Assessment and Plan of Treatment</p><p><b>Author</b>: P., MD, Cardio</p><p><b>Effective Period</b>: Oct 4, 2021 2:46 P.M. UTC</p></div>"
|
|
||||||
},
|
|
||||||
"status": "active",
|
|
||||||
"intent": "plan",
|
|
||||||
"category": [
|
|
||||||
{
|
|
||||||
"coding": [
|
|
||||||
{
|
|
||||||
"system": "http://hl7.org/fhir/us/core/CodeSystem/careplan-category",
|
|
||||||
"code": "assess-plan",
|
|
||||||
"display": "Assessment and Plan of Treatment"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"text": "Assessment and Plan of Treatment"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Comorbidities found via Retrieve Dx",
|
|
||||||
"subject": {
|
|
||||||
"reference": "Patient/12724066",
|
|
||||||
"display": "SMART, NANCYU NHA EEEEEE"
|
|
||||||
},
|
|
||||||
"encounter": {
|
|
||||||
"reference": "Encounter/97961321"
|
|
||||||
},
|
|
||||||
"period": {
|
|
||||||
"start": "2021-10-04T14:46:33.000Z"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"reference": "Practitioner/12763770",
|
|
||||||
"display": "P., MD, Cardio"
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,26 +0,0 @@
|
||||||
import {IClient} from '../interface';
|
|
||||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../models/client/client-config';
|
|
||||||
|
|
||||||
export class BlueButtonClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BlueButton overrides the SyncAll function because Patient-everything operation is not available.
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const supportedResources: string[] = [
|
|
||||||
"ExplanationOfBenefit",
|
|
||||||
"Coverage",
|
|
||||||
]
|
|
||||||
|
|
||||||
return this.SyncAllByResourceName(db, supportedResources)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
import {IClient} from '../interface';
|
|
||||||
import {FHIR401Client} from './base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../models/database/source';
|
|
||||||
import {ClientConfig} from '../../models/client/client-config';
|
|
||||||
|
|
||||||
export class CignaClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class CareEvolutionClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
//CareEvolution API requires the following Accept header for every request
|
|
||||||
this.headers.set("Accept","application/json+fhir")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class CernerClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
//Cerner API requires the following Accept header for every request
|
|
||||||
this.headers.set("Accept","application/json+fhir")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cerner overrides the SyncAll function because Patient-everything operation is not available.
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const supportedResources: string[] = this.usCoreResources.concat([
|
|
||||||
"Account",
|
|
||||||
"Appointment",
|
|
||||||
"Consent",
|
|
||||||
"FamilyMemberHistory",
|
|
||||||
"InsurancePlan",
|
|
||||||
"MedicationRequest",
|
|
||||||
"NutritionOrder",
|
|
||||||
"Person",
|
|
||||||
"Provenance",
|
|
||||||
"Questionnaire",
|
|
||||||
"QuestionnaireResponse",
|
|
||||||
"RelatedPerson",
|
|
||||||
"Schedule",
|
|
||||||
"ServiceRequest",
|
|
||||||
"Slot",
|
|
||||||
])
|
|
||||||
|
|
||||||
return this.SyncAllByResourceName(db, supportedResources)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class EpicClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
//Epic API requires the following Accept header for every request
|
|
||||||
this.headers.set("Accept","application/json+fhir")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Epic overrides the SyncAll function because Patient-everything operation is not available.
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const supportedResources: string[] = this.usCoreResources.concat([
|
|
||||||
"Consent",
|
|
||||||
"FamilyMemberHistory",
|
|
||||||
"InsurancePlan",
|
|
||||||
"MedicationRequest",
|
|
||||||
"NutritionOrder",
|
|
||||||
"Person",
|
|
||||||
"Provenance",
|
|
||||||
"Questionnaire",
|
|
||||||
"QuestionnaireResponse",
|
|
||||||
"RelatedPerson",
|
|
||||||
"Schedule",
|
|
||||||
"ServiceRequest",
|
|
||||||
"Slot",
|
|
||||||
])
|
|
||||||
|
|
||||||
return this.SyncAllByResourceName(db, supportedResources)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class AthenaClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Athena overrides the SyncAll function because Patient-everything operation is not available.
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const supportedResources: string[] = this.usCoreResources
|
|
||||||
|
|
||||||
return this.SyncAllByResourceName(db, supportedResources)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class HealthITClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
//HealthIT API requires the following Accept header for every request
|
|
||||||
this.headers.set("Accept","application/json+fhir")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HealthIT overrides the SyncAll function because Patient-everything operation is not available.
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
|
|
||||||
const supportedResources: string[] = this.usCoreResources.concat([
|
|
||||||
"Consent",
|
|
||||||
"FamilyMemberHistory",
|
|
||||||
"InsurancePlan",
|
|
||||||
"MedicationRequest",
|
|
||||||
"NutritionOrder",
|
|
||||||
"Person",
|
|
||||||
"Provenance",
|
|
||||||
"Questionnaire",
|
|
||||||
"QuestionnaireResponse",
|
|
||||||
"RelatedPerson",
|
|
||||||
"Schedule",
|
|
||||||
"ServiceRequest",
|
|
||||||
"Slot",
|
|
||||||
])
|
|
||||||
|
|
||||||
return this.SyncAllByResourceName(db, supportedResources)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
import {IClient} from '../../interface';
|
|
||||||
import {FHIR401Client} from '../base/fhir401_r4_client';
|
|
||||||
import {Source} from '../../../models/database/source';
|
|
||||||
import {IDatabaseRepository} from '../../../database/interface';
|
|
||||||
import {ClientConfig} from '../../../models/client/client-config';
|
|
||||||
|
|
||||||
export class LogicaClient extends FHIR401Client implements IClient {
|
|
||||||
constructor(source: Source, clientConfig: ClientConfig) {
|
|
||||||
super(source, clientConfig);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
import {IDatabaseRepository} from '../database/interface';
|
|
||||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
|
||||||
import {Source} from '../models/database/source';
|
|
||||||
|
|
||||||
export interface IClient {
|
|
||||||
fhirVersion: string
|
|
||||||
source: Source
|
|
||||||
GetRequest(resourceSubpath: string): Promise<any>
|
|
||||||
GetFhirVersion(): Promise<any>
|
|
||||||
RefreshSourceToken(): Promise<boolean>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function attempts to retrieve a Patient Bundle and sync all resources to the database
|
|
||||||
* @param db
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
SyncAll(db: IDatabaseRepository): Promise<UpsertSummary>
|
|
||||||
|
|
||||||
|
|
||||||
SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>
|
|
||||||
|
|
||||||
//Manual client ONLY functions
|
|
||||||
SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary>
|
|
||||||
}
|
|
||||||
|
|
||||||
//This is the "raw" Fhir resource
|
|
||||||
export interface IResourceRaw {
|
|
||||||
resourceType: string
|
|
||||||
id?: string
|
|
||||||
meta?: IResourceMetaRaw
|
|
||||||
}
|
|
||||||
// This is the "raw" Fhir Bundle resource
|
|
||||||
export interface IResourceBundleRaw {
|
|
||||||
resourceType: string
|
|
||||||
id?: string
|
|
||||||
entry: IResourceBundleEntryRaw[]
|
|
||||||
total?: number
|
|
||||||
link?: IResourceBundleLinkRaw[]
|
|
||||||
meta?: IResourceMetaRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IResourceBundleLinkRaw {
|
|
||||||
id?: string
|
|
||||||
relation: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IResourceBundleEntryRaw {
|
|
||||||
id?: string
|
|
||||||
fullUrl?: string
|
|
||||||
resource: IResourceRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IResourceMetaRaw {
|
|
||||||
id?: string
|
|
||||||
versionId?: string
|
|
||||||
lastUpdated: string
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
export enum DocType {
|
|
||||||
Source = "source",
|
|
||||||
ResourceFhir = "resource_fhir"
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
import {Source} from '../models/database/source';
|
|
||||||
import {ResourceFhir} from '../models/database/resource_fhir';
|
|
||||||
import {SourceSummary} from '../models/fasten/source-summary';
|
|
||||||
import {Summary} from '../models/fasten/summary';
|
|
||||||
import {User} from '../models/fasten/user';
|
|
||||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
|
||||||
// import {SourceSummary} from '../../app/models/fasten/source-summary';
|
|
||||||
|
|
||||||
export interface IDatabaseDocument {
|
|
||||||
_id?: string
|
|
||||||
_rev?: string
|
|
||||||
doc_type: string
|
|
||||||
updated_at?: string
|
|
||||||
|
|
||||||
populateId(): void
|
|
||||||
base64Id(): string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IDatabasePaginatedResponse {
|
|
||||||
offset: number,
|
|
||||||
total_rows: number,
|
|
||||||
rows: any[]
|
|
||||||
}
|
|
||||||
export interface IDatabaseRepository {
|
|
||||||
GetDB(skipEncryption?: boolean): any
|
|
||||||
Close(): void
|
|
||||||
|
|
||||||
// CreateUser(*models.User) error
|
|
||||||
// GetUserByEmail(context.Context, string) (*models.User, error)
|
|
||||||
// GetCurrentUser(context.Context) *models.User
|
|
||||||
|
|
||||||
UpsertSource(source: Source): Promise<UpsertSummary>
|
|
||||||
GetSource(source_id: string): Promise<Source>
|
|
||||||
DeleteSource(source_id: string): Promise<boolean>
|
|
||||||
GetSourceSummary(source_id: string): Promise<SourceSummary>
|
|
||||||
GetSources(): Promise<IDatabasePaginatedResponse>
|
|
||||||
IsDatabasePopulated(): Promise<boolean>
|
|
||||||
|
|
||||||
// UpsertResource(context.Context, *models.ResourceFhir) error
|
|
||||||
// GetResourceBySourceType(context.Context, string, string) (*models.ResourceFhir, error)
|
|
||||||
// GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error)
|
|
||||||
// ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error)
|
|
||||||
// GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
|
|
||||||
UpsertResource(resource: ResourceFhir): Promise<UpsertSummary>
|
|
||||||
UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary>
|
|
||||||
GetResource(resource_id: string): Promise<ResourceFhir>
|
|
||||||
GetResources(): Promise<IDatabasePaginatedResponse>
|
|
||||||
GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse>
|
|
||||||
}
|
|
|
@ -1,159 +0,0 @@
|
||||||
// This is a Typescript module that recreates the functionality defined in https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
|
||||||
// This file only exists because the PouchDB crypto plugin must work in both the browser and web-worker environment (where `window` is
|
|
||||||
// undefined and causes errors).
|
|
||||||
// Also, crypto-pouch does not support storing encrypted data in the remote database by default, which I'm attempting to do by commenting out the
|
|
||||||
// NO_COUCH error.
|
|
||||||
//
|
|
||||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
|
||||||
// failed for a variety of reasons, so instead we're using a PouchdbCrypto class with static methods to re-implement the crypto logic
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// See:
|
|
||||||
// - https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
|
||||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
|
||||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
|
||||||
|
|
||||||
import * as Crypt from 'garbados-crypt';
|
|
||||||
import { openDB, deleteDB, wrap, unwrap } from 'idb';
|
|
||||||
|
|
||||||
// const Crypt = require()
|
|
||||||
|
|
||||||
const LOCAL_ID = '_local/crypto'
|
|
||||||
const IGNORE = ['_id', '_rev', '_deleted', '_conflicts']
|
|
||||||
|
|
||||||
// const NO_COUCH = 'crypto-pouch does not work with pouchdb\'s http adapter. Use a local adapter instead.'
|
|
||||||
|
|
||||||
export class PouchdbCryptoOptions {
|
|
||||||
ignore?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PouchdbCryptConfig {
|
|
||||||
username: string
|
|
||||||
key: string
|
|
||||||
config: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class PouchdbCrypto {
|
|
||||||
private static async localIdb(){
|
|
||||||
const dbPromise = openDB('crypto-store', 1, {
|
|
||||||
upgrade(db) {
|
|
||||||
db.createObjectStore('crypto');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return await dbPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static async CryptConfig(key: string, username): Promise<PouchdbCryptConfig>{
|
|
||||||
const _crypt = new Crypt(key)
|
|
||||||
let exportString = await _crypt.export()
|
|
||||||
return {username: username, key: key, config: exportString}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async StoreCryptConfig(cryptConfig: PouchdbCryptConfig) {
|
|
||||||
const localDb = await PouchdbCrypto.localIdb()
|
|
||||||
await localDb.put('crypto', JSON.stringify(cryptConfig), `encryption_data_${cryptConfig.username}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async RetrieveCryptConfig(currentUser: string): Promise<PouchdbCryptConfig>{
|
|
||||||
const localDb = await PouchdbCrypto.localIdb()
|
|
||||||
let cryptConfigStr = await localDb.get('crypto',`encryption_data_${currentUser}`)
|
|
||||||
if(!cryptConfigStr){
|
|
||||||
throw new Error("crypto configuration not set")
|
|
||||||
}
|
|
||||||
return JSON.parse(cryptConfigStr) as PouchdbCryptConfig
|
|
||||||
}
|
|
||||||
public static async DeleteCryptConfig(currentUser: string): Promise<void>{
|
|
||||||
const localDb = await PouchdbCrypto.localIdb()
|
|
||||||
return await localDb.delete('crypto',`encryption_data_${currentUser}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static async crypto(db, cryptConfig: PouchdbCryptConfig, options: PouchdbCryptoOptions = {}) {
|
|
||||||
// if (db.adapter === 'http') {
|
|
||||||
// throw new Error(NO_COUCH)
|
|
||||||
// }
|
|
||||||
// if (typeof password === 'object') {
|
|
||||||
// // handle `db.crypto({ password, ...options })`
|
|
||||||
// options = password
|
|
||||||
// password = password.password
|
|
||||||
// delete options.password
|
|
||||||
// }
|
|
||||||
// setup ignore list
|
|
||||||
db._ignore = IGNORE.concat(options.ignore || [])
|
|
||||||
|
|
||||||
if(!cryptConfig || !cryptConfig.key || !cryptConfig.config){
|
|
||||||
throw new Error("crypto configuration file is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// setup crypto helper
|
|
||||||
const trySetup = async () => {
|
|
||||||
// try saving credentials to a local doc
|
|
||||||
try {
|
|
||||||
// // first we try to get saved creds from the local doc
|
|
||||||
// const localDb = await PouchdbCrypto.localIdb()
|
|
||||||
// let exportString = await localDb.get('crypto',`encryption_data_${db.name}`)
|
|
||||||
// if(!exportString){
|
|
||||||
// // no existing encryption key found
|
|
||||||
//
|
|
||||||
// // do first-time setup
|
|
||||||
// db._crypt = new Crypt(password)
|
|
||||||
// let exportString = await db._crypt.export()
|
|
||||||
// await localDb.put('crypto', exportString, `encryption_data_${db.name}`)
|
|
||||||
// } else {
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
|
|
||||||
db._crypt = await Crypt.import(cryptConfig.key, cryptConfig.config)
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await trySetup()
|
|
||||||
// instrument document transforms
|
|
||||||
db.transform({
|
|
||||||
incoming: async (doc) => {
|
|
||||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
|
||||||
if (!db._crypt) {
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
if (doc._attachments && !db._ignore.includes('_attachments')) {
|
|
||||||
throw new Error('Attachments cannot be encrypted. Use {ignore: "_attachments"} option')
|
|
||||||
}
|
|
||||||
let encrypted: any = {}
|
|
||||||
for (let key of db._ignore) {
|
|
||||||
// attach ignored fields to encrypted doc
|
|
||||||
if (key in doc) encrypted[key] = doc[key]
|
|
||||||
}
|
|
||||||
encrypted.payload = await db._crypt.encrypt(JSON.stringify(doc))
|
|
||||||
return encrypted
|
|
||||||
},
|
|
||||||
outgoing: async (doc) => {
|
|
||||||
return await this.decryptDocument(db, doc)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
public static async decryptDocument(db, doc):Promise<any>{
|
|
||||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
|
||||||
if (!db._crypt) { return doc }
|
|
||||||
let decryptedString = await db._crypt.decrypt(doc.payload)
|
|
||||||
let decrypted = JSON.parse(decryptedString)
|
|
||||||
for (let key of db._ignore) {
|
|
||||||
// patch decrypted doc with ignored fields
|
|
||||||
if (key in doc) decrypted[key] = doc[key]
|
|
||||||
}
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
public static removeCrypto(db) {
|
|
||||||
delete db._crypt
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
// This is a Typescript module that recreates the functionality defined in https://github.com/pouchdb/upsert/blob/master/index.js
|
|
||||||
// This file only exists because the PouchDB upsert plugin must work in both the browser and web-worker environment (where `window` is
|
|
||||||
// undefined and causes errors).
|
|
||||||
//
|
|
||||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
|
||||||
// failed for a variety of reasons, so instead we're using a PouchdbUpsert class with static methods to re-implement the upsert logic
|
|
||||||
//
|
|
||||||
// See:
|
|
||||||
// - https://github.com/pouchdb/upsert/blob/master/index.js
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
|
||||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
|
||||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
|
||||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
|
||||||
export class PouchdbUpsert {
|
|
||||||
public static upsert(db, docId, diffFun, cb?) {
|
|
||||||
var promise = PouchdbUpsert.upsertInner(db, docId, diffFun);
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
promise.then(function(resp) {
|
|
||||||
cb(null, resp);
|
|
||||||
}, cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
public static putIfNotExists(db, docId, doc, cb?) {
|
|
||||||
if (typeof docId !== 'string') {
|
|
||||||
cb = doc;
|
|
||||||
doc = docId;
|
|
||||||
docId = doc._id;
|
|
||||||
}
|
|
||||||
|
|
||||||
var diffFun = function(existingDoc) {
|
|
||||||
if (existingDoc._rev) {
|
|
||||||
return false; // do nothing
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
};
|
|
||||||
|
|
||||||
var promise = PouchdbUpsert.upsertInner(db, docId, diffFun);
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
promise.then(function(resp) {
|
|
||||||
cb(null, resp);
|
|
||||||
}, cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// private methods
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// this is essentially the "update sugar" function from daleharvey/pouchdb#1388
|
|
||||||
// the diffFun tells us what delta to apply to the doc. it either returns
|
|
||||||
// the doc, or false if it doesn't need to do an update after all
|
|
||||||
private static upsertInner(db, docId, diffFun) {
|
|
||||||
if (typeof docId !== 'string') {
|
|
||||||
return Promise.reject(new Error('doc id is required'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.get(docId).catch(function (err) {
|
|
||||||
/* istanbul ignore next */
|
|
||||||
if (err.status !== 404) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}).then(function (doc) {
|
|
||||||
// the user might change the _rev, so save it for posterity
|
|
||||||
var docRev = doc._rev;
|
|
||||||
var newDoc = diffFun(doc);
|
|
||||||
|
|
||||||
if (!newDoc) {
|
|
||||||
// if the diffFun returns falsy, we short-circuit as
|
|
||||||
// an optimization
|
|
||||||
return { updated: false, rev: docRev, id: docId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// users aren't allowed to modify these values,
|
|
||||||
// so reset them here
|
|
||||||
newDoc._id = docId;
|
|
||||||
newDoc._rev = docRev;
|
|
||||||
return PouchdbUpsert.tryAndPut(db, newDoc, diffFun);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static tryAndPut(db, doc, diffFun) {
|
|
||||||
return db.put(doc).then((res) => {
|
|
||||||
return {
|
|
||||||
updated: true,
|
|
||||||
rev: res.rev,
|
|
||||||
id: doc._id
|
|
||||||
};
|
|
||||||
}, (err) => {
|
|
||||||
/* istanbul ignore next */
|
|
||||||
if (err.status !== 409) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return this.upsertInner(db, doc._id, diffFun);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
import {IDatabaseRepository} from './interface';
|
|
||||||
import {NewPouchdbRepositoryWebWorker} from './pouchdb_repository';
|
|
||||||
import {SourceType} from '../models/database/source_types';
|
|
||||||
import {Source} from '../models/database/source';
|
|
||||||
import {DocType} from './constants';
|
|
||||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import {PouchdbCrypto} from './plugins/crypto';
|
|
||||||
|
|
||||||
describe('PouchdbRepository', () => {
|
|
||||||
let repository: IDatabaseRepository;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
let current_user = uuidv4() as string
|
|
||||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
|
||||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
|
||||||
repository = NewPouchdbRepositoryWebWorker({current_user: current_user, auth_token: ""}, '/database', new PouchDB("PouchdbRepository" + current_user));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if(repository){
|
|
||||||
const db = await repository.GetDB()
|
|
||||||
db.destroy() //wipe the db.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(repository).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CreateSource', () => {
|
|
||||||
it('should return an id', async () => {
|
|
||||||
const createdId = await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(createdId.totalResources).toEqual(1);
|
|
||||||
expect(createdId.updatedResources[0]).toEqual("source:aetna:patient");
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GetSource', () => {
|
|
||||||
it('should return an source', async () => {
|
|
||||||
const createdResource = await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
access_token: 'hello-world',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const createdSource = await repository.GetSource(createdResource.updatedResources[0])
|
|
||||||
|
|
||||||
expect(createdSource.doc_type).toEqual(DocType.Source);
|
|
||||||
expect(createdSource.patient).toEqual('patient');
|
|
||||||
expect(createdSource.source_type).toEqual(SourceType.Aetna);
|
|
||||||
expect(createdSource.access_token).toEqual('hello-world');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('DeleteSource', () => {
|
|
||||||
it('should delete a source', async () => {
|
|
||||||
const createdResource = await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient-to-delete',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
access_token: 'hello-world',
|
|
||||||
}))
|
|
||||||
console.log(createdResource)
|
|
||||||
const deletedSource = await repository.DeleteSource(createdResource.updatedResources[0])
|
|
||||||
|
|
||||||
expect(deletedSource).toBeTruthy();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GetSources', () => {
|
|
||||||
it('should return a list of sources', async () => {
|
|
||||||
await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient1',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
access_token: 'hello-world1',
|
|
||||||
}))
|
|
||||||
|
|
||||||
await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient2',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
access_token: 'hello-world2',
|
|
||||||
}))
|
|
||||||
|
|
||||||
await repository.UpsertSource(new Source({
|
|
||||||
patient: 'patient3',
|
|
||||||
source_type: SourceType.Aetna,
|
|
||||||
access_token: 'hello-world3',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const sourcesWrapped = await repository.GetSources()
|
|
||||||
|
|
||||||
expect(sourcesWrapped.total_rows).toEqual(3);
|
|
||||||
expect((sourcesWrapped.rows[0] as Source).patient).toEqual('patient1');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
|
@ -1,465 +0,0 @@
|
||||||
import {Source} from '../models/database/source';
|
|
||||||
import {IDatabasePaginatedResponse, IDatabaseDocument, IDatabaseRepository} from './interface';
|
|
||||||
import {DocType} from './constants';
|
|
||||||
import {ResourceFhir} from '../models/database/resource_fhir';
|
|
||||||
import {ResourceTypeCounts, SourceSummary} from '../models/fasten/source-summary';
|
|
||||||
import {Base64} from '../utils/base64';
|
|
||||||
import {GetEndpointAbsolutePath} from '../utils/endpoint_absolute_path';
|
|
||||||
|
|
||||||
// PouchDB & plugins
|
|
||||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
|
||||||
|
|
||||||
// import * as PouchCrypto from 'crypto-pouch';
|
|
||||||
// PouchDB.plugin(PouchCrypto);
|
|
||||||
|
|
||||||
import * as PouchTransform from 'transform-pouch';
|
|
||||||
PouchDB.plugin(PouchTransform);
|
|
||||||
|
|
||||||
import {PouchdbUpsert} from './plugins/upsert';
|
|
||||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
|
||||||
|
|
||||||
import {PouchdbCryptConfig, PouchdbCrypto, PouchdbCryptoOptions} from './plugins/crypto';
|
|
||||||
import {utils} from 'protractor';
|
|
||||||
|
|
||||||
// !!!!!!!!!!!!!!!!WARNING!!!!!!!!!!!!!!!!!!!!!
|
|
||||||
// most pouchdb plugins seem to fail when used in a webworker.
|
|
||||||
// !!!!!!!!!!!!!!!!WARNING!!!!!!!!!!!!!!!!!!!!!
|
|
||||||
// import * as PouchUpsert from 'pouchdb-upsert';
|
|
||||||
// PouchDB.plugin(PouchUpsert);
|
|
||||||
// import find from 'pouchdb-find';
|
|
||||||
// PouchDB.plugin(find);
|
|
||||||
// PouchDB.debug.enable('pouchdb:find')
|
|
||||||
|
|
||||||
// import * as rawUpsert from 'pouchdb-upsert';
|
|
||||||
// const upsert: PouchDB.Plugin = (rawUpsert as any);
|
|
||||||
// PouchDB.plugin(upsert);
|
|
||||||
|
|
||||||
// import {PouchdbUpsert} from './plugins/upsert';
|
|
||||||
// const upsert = new PouchdbUpsert()
|
|
||||||
// console.log("typeof PouchdbUpsert",typeof upsert, upsert)
|
|
||||||
// PouchDB.plugin(upsert.default)
|
|
||||||
|
|
||||||
// YOU MUST USE globalThis not window or self.
|
|
||||||
// YOU MUST NOT USE console.* as its not available in a webworker context
|
|
||||||
|
|
||||||
|
|
||||||
// this is required, otherwise PouchFind fails when looking for the global PouchDB variable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method is used to initialize the repository from Workers.
|
|
||||||
* Eventually this method should dyanmically dtermine the version of the repo to return from the env.
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function NewPouchdbRepositoryWebWorker(auth: {current_user: string, auth_token: string}, couchDbEndpointBase: string, localPouchDb?: PouchDB.Database): PouchdbRepository {
|
|
||||||
let pouchdbRepository = new PouchdbRepository(couchDbEndpointBase, localPouchDb)
|
|
||||||
pouchdbRepository.current_user = auth.current_user
|
|
||||||
pouchdbRepository._auth_token = auth.auth_token
|
|
||||||
return pouchdbRepository
|
|
||||||
}
|
|
||||||
export class PouchdbRepository implements IDatabaseRepository {
|
|
||||||
|
|
||||||
// replicationHandler: any
|
|
||||||
remotePouchEndpoint: string // "http://localhost:5984"
|
|
||||||
pouchDb: PouchDB.Database
|
|
||||||
current_user: string
|
|
||||||
_auth_token: string
|
|
||||||
|
|
||||||
//encryption configuration
|
|
||||||
cryptConfig: PouchdbCryptConfig = null
|
|
||||||
encryptionInitComplete: boolean = false
|
|
||||||
|
|
||||||
// There are 3 different ways to initialize the Database
|
|
||||||
// - explicitly after signin/signup (not supported by this class, see FastenDbService)
|
|
||||||
// - explicitly during web-worker init
|
|
||||||
// - implicitly after Lighthouse redirect (not supported by this class, see FastenDbService)
|
|
||||||
// Three peices of information are required during intialization
|
|
||||||
// - couchdb endpoint (constant, see environment.couchdb_endpoint_base)
|
|
||||||
// - username
|
|
||||||
// - JWT token
|
|
||||||
constructor(couchDbEndpointBase: string, localPouchDb?: PouchDB.Database) {
|
|
||||||
// couchDbEndpointBase could be a relative or absolute path.
|
|
||||||
//if its absolute, we should pass it in, as-is
|
|
||||||
this.remotePouchEndpoint = GetEndpointAbsolutePath(globalThis.location, couchDbEndpointBase)
|
|
||||||
|
|
||||||
|
|
||||||
//setup PouchDB Plugins
|
|
||||||
//https://pouchdb.com/guides/mango-queries.html
|
|
||||||
this.pouchDb = null
|
|
||||||
|
|
||||||
if(localPouchDb){
|
|
||||||
console.warn("using local pouchdb, this should only be used for testing")
|
|
||||||
this.pouchDb = localPouchDb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Teardown / deconfigure the existing database instance (if there is one).
|
|
||||||
// --
|
|
||||||
// CAUTION: Subsequent calls to .GetDB() will fail until a new instance is configured
|
|
||||||
// with a call to .ConfigureForUser().
|
|
||||||
public async Close(): Promise<void> {
|
|
||||||
if (!this.pouchDb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Stop remote replication for existing database
|
|
||||||
// if(this.replicationHandler){
|
|
||||||
// this.replicationHandler.cancel()
|
|
||||||
// }
|
|
||||||
|
|
||||||
this.pouchDb.close();
|
|
||||||
this.pouchDb = null;
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Source
|
|
||||||
|
|
||||||
public async UpsertSource(source: Source): Promise<UpsertSummary> {
|
|
||||||
return this.upsertDocument(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetSource(source_id: string): Promise<Source> {
|
|
||||||
return this.getDocument(source_id)
|
|
||||||
.then((doc) => {
|
|
||||||
return new Source(doc)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetSources(): Promise<IDatabasePaginatedResponse> {
|
|
||||||
return this.findDocumentByDocType(DocType.Source)
|
|
||||||
.then((docWrapper) => {
|
|
||||||
|
|
||||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
|
||||||
return new Source(result.doc)
|
|
||||||
})
|
|
||||||
return docWrapper
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetSourceSummary(source_id: string): Promise<SourceSummary> {
|
|
||||||
const sourceSummary = new SourceSummary()
|
|
||||||
sourceSummary.source = await this.GetSource(source_id)
|
|
||||||
sourceSummary.patient = await this.findDocumentByPrefix(`${DocType.ResourceFhir}:${Base64.Encode(source_id)}:Patient`, true)
|
|
||||||
.then((paginatedResp) => new ResourceFhir(paginatedResp?.rows[0].doc))
|
|
||||||
|
|
||||||
sourceSummary.resource_type_counts = await this.findDocumentByPrefix(`${DocType.ResourceFhir}:${Base64.Encode(source_id)}`, false)
|
|
||||||
.then((paginatedResp) => {
|
|
||||||
const lookup: {[name: string]: ResourceTypeCounts} = {}
|
|
||||||
paginatedResp?.rows.forEach((resourceWrapper) => {
|
|
||||||
const resourceIdParts = resourceWrapper.id.split(':')
|
|
||||||
const resourceType = resourceIdParts[2]
|
|
||||||
|
|
||||||
let currentResourceStats = lookup[resourceType] || {
|
|
||||||
count: 0,
|
|
||||||
source_id: Base64.Decode(resourceIdParts[1]),
|
|
||||||
resource_type: resourceType
|
|
||||||
}
|
|
||||||
currentResourceStats.count += 1
|
|
||||||
lookup[resourceType] = currentResourceStats
|
|
||||||
})
|
|
||||||
|
|
||||||
const arr = []
|
|
||||||
for(let key in lookup){
|
|
||||||
arr.push(lookup[key])
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
})
|
|
||||||
return sourceSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
public async DeleteSource(source_id: string): Promise<boolean> {
|
|
||||||
return this.deleteDocument(source_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Resource
|
|
||||||
|
|
||||||
public async UpsertResource(resource: ResourceFhir): Promise<UpsertSummary> {
|
|
||||||
return this.upsertDocument(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary> {
|
|
||||||
return this.upsertBulk(resources);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetResource(resource_id: string): Promise<ResourceFhir> {
|
|
||||||
return this.getDocument(resource_id)
|
|
||||||
.then((doc) => {
|
|
||||||
return new ResourceFhir(doc)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetResources(): Promise<IDatabasePaginatedResponse> {
|
|
||||||
return this.findDocumentByDocType(DocType.ResourceFhir)
|
|
||||||
.then((docWrapper) => {
|
|
||||||
|
|
||||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
|
||||||
return new ResourceFhir(result.doc)
|
|
||||||
})
|
|
||||||
return docWrapper
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse> {
|
|
||||||
let prefix = `${DocType.ResourceFhir}:${Base64.Encode(source_id)}`
|
|
||||||
if(source_resource_type){
|
|
||||||
prefix += `:${source_resource_type}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findDocumentByPrefix(prefix, true)
|
|
||||||
.then((docWrapper) => {
|
|
||||||
|
|
||||||
docWrapper.rows = docWrapper.rows.map((result) => {
|
|
||||||
return new ResourceFhir(result.doc)
|
|
||||||
})
|
|
||||||
return docWrapper
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* given an raw connection to a database, determine how many records/resources are stored within
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async IsDatabasePopulated(): Promise<boolean> {
|
|
||||||
let resourceFhirCount = await this.findDocumentByPrefix(DocType.ResourceFhir, false, true)
|
|
||||||
.then((resp) => {
|
|
||||||
console.log("RESPONSE COUNT INFO", resp)
|
|
||||||
return resp.rows.length
|
|
||||||
})
|
|
||||||
|
|
||||||
if(resourceFhirCount > 0) {return true}
|
|
||||||
|
|
||||||
|
|
||||||
let sourceCount = await this.findDocumentByPrefix(DocType.Source, false, true)
|
|
||||||
.then((resp) => {
|
|
||||||
console.log("SOURCE COUNT INFO", resp)
|
|
||||||
|
|
||||||
return resp.rows.length
|
|
||||||
})
|
|
||||||
if(sourceCount > 0) {return true}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// CRUD Operators
|
|
||||||
// All functions below here will return the raw PouchDB responses, and may need to be wrapped in
|
|
||||||
// new ResourceFhir(result.doc)
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
public ResetDB(){
|
|
||||||
this.pouchDb = null
|
|
||||||
this.encryptionInitComplete = false
|
|
||||||
}
|
|
||||||
// Get the active PouchDB instance. Throws an error if no PouchDB instance is
|
|
||||||
// available (ie, user has not yet been configured with call to .configureForUser()).
|
|
||||||
public async GetDB(skipEncryption: boolean = false): Promise<PouchDB.Database> {
|
|
||||||
await this.GetSessionDB()
|
|
||||||
|
|
||||||
if(!this.pouchDb) {
|
|
||||||
throw(new Error( "Database is not available - please configure an instance." ));
|
|
||||||
}
|
|
||||||
|
|
||||||
if(skipEncryption){
|
|
||||||
//this allows the database to be queried, even when encryption has not been configured correctly
|
|
||||||
//this will only be used to take a count of documents in the database, so we can prompt the user for a encryption key, or generate a new one (for an empty db)
|
|
||||||
return this.pouchDb
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//try to determine the crypto configuration using the currently logged in user.
|
|
||||||
this.cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
|
||||||
|
|
||||||
if(!this.cryptConfig){
|
|
||||||
throw new Error("crypto configuration not set.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!this.encryptionInitComplete){
|
|
||||||
return PouchdbCrypto.crypto(this.pouchDb, this.cryptConfig, {ignore:[
|
|
||||||
'doc_type',
|
|
||||||
'source_id',
|
|
||||||
'source_resource_type',
|
|
||||||
'source_resource_id',
|
|
||||||
]})
|
|
||||||
.then((encryptedPouchDb) => {
|
|
||||||
this.pouchDb = encryptedPouchDb
|
|
||||||
this.encryptionInitComplete = true
|
|
||||||
return this.pouchDb
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return this.pouchDb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// update/insert a new document. Returns a promise of the generated id.
|
|
||||||
protected upsertDocument(newDoc: IDatabaseDocument) : Promise<UpsertSummary> {
|
|
||||||
// make sure we always "populate" the ID for every document before submitting
|
|
||||||
newDoc.populateId()
|
|
||||||
|
|
||||||
// NOTE: All friends are given the key-prefix of "friend:". This way, when we go
|
|
||||||
// to query for friends, we can limit the scope to keys with in this key-space.
|
|
||||||
|
|
||||||
return this.GetDB()
|
|
||||||
.then((db) => {
|
|
||||||
return PouchdbUpsert.upsert(db, newDoc._id, (existingDoc: IDatabaseDocument) => {
|
|
||||||
//diffFunc - function that takes the existing doc as input and returns an updated doc.
|
|
||||||
// If this diffFunc returns falsey, then the update won't be performed (as an optimization).
|
|
||||||
// If the document does not already exist, then {} will be the input to diffFunc.
|
|
||||||
|
|
||||||
const isExistingEmpty = Object.keys(existingDoc).length === 0
|
|
||||||
if(isExistingEmpty){
|
|
||||||
//always return new doc (and set update_at if not already set)
|
|
||||||
//if this is a ResourceFhir doc, see if theres a updatedDate already
|
|
||||||
if(newDoc.doc_type == DocType.ResourceFhir){
|
|
||||||
newDoc.updated_at = newDoc.updated_at || (newDoc as any).meta?.updated_at
|
|
||||||
}
|
|
||||||
newDoc.updated_at = newDoc.updated_at || (new Date().toISOString())
|
|
||||||
// console.log("merge, empty")
|
|
||||||
return newDoc
|
|
||||||
}
|
|
||||||
|
|
||||||
if(newDoc.doc_type == DocType.ResourceFhir){
|
|
||||||
|
|
||||||
//for resourceFhir docs, we only care about comparing the resource_raw content
|
|
||||||
const existingContent = JSON.stringify((existingDoc as ResourceFhir).resource_raw)
|
|
||||||
const newContent = JSON.stringify((newDoc as ResourceFhir).resource_raw)
|
|
||||||
if(existingContent == newContent){
|
|
||||||
return false //do not update
|
|
||||||
} else {
|
|
||||||
//theres a difference. Set the updated_at date if possible, otherwise use the current date
|
|
||||||
(newDoc as ResourceFhir).updated_at = (newDoc as any).meta?.updated_at || (new Date().toISOString())
|
|
||||||
return newDoc
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if(newDoc.doc_type == DocType.Source){
|
|
||||||
delete existingDoc._rev
|
|
||||||
const existingContent = JSON.stringify(existingDoc)
|
|
||||||
const newContent = JSON.stringify(newDoc)
|
|
||||||
if(existingContent == newContent){
|
|
||||||
return false //do not update, content is the same for source object
|
|
||||||
} else {
|
|
||||||
//theres a difference. Set the updated_at date
|
|
||||||
(newDoc as Source).updated_at = (new Date().toISOString())
|
|
||||||
return { ...existingDoc, ...newDoc };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} else {
|
|
||||||
let errMsg = "unknown doc_type, cannot diff for upsert: " + newDoc.doc_type
|
|
||||||
console.error(errMsg)
|
|
||||||
throw new Error(errMsg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(( result ): UpsertSummary => {
|
|
||||||
// // success, res is {rev: '1-xxx', updated: true, id: 'myDocId'}
|
|
||||||
const updateSummary = new UpsertSummary()
|
|
||||||
updateSummary.totalResources = 1
|
|
||||||
|
|
||||||
if(result.updated){
|
|
||||||
updateSummary.updatedResources = [result.id]
|
|
||||||
}
|
|
||||||
return updateSummary;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async upsertBulk(docs: IDatabaseDocument[]): Promise<UpsertSummary> {
|
|
||||||
//insert sequentially (not in parallel)
|
|
||||||
let finalUpsertSummary = new UpsertSummary()
|
|
||||||
|
|
||||||
for (let doc of docs){
|
|
||||||
doc.populateId();
|
|
||||||
let upsertSummary = await this.upsertDocument(doc)
|
|
||||||
finalUpsertSummary.totalResources += upsertSummary.totalResources
|
|
||||||
finalUpsertSummary.updatedResources = finalUpsertSummary.updatedResources.concat(upsertSummary.updatedResources)
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalUpsertSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDocument(id: string): Promise<any> {
|
|
||||||
return this.GetDB()
|
|
||||||
.then((db) => db.get(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
protected findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
|
||||||
return this.findDocumentByPrefix(docType, includeDocs)
|
|
||||||
}
|
|
||||||
protected findDocumentByPrefix(prefix: string, includeDocs: boolean = true, skipEncryption: boolean = false): Promise<IDatabasePaginatedResponse> {
|
|
||||||
return this.GetDB(skipEncryption)
|
|
||||||
.then((db) => {
|
|
||||||
return db.allDocs({
|
|
||||||
include_docs: includeDocs,
|
|
||||||
startkey: `${prefix}:`,
|
|
||||||
endkey: `${prefix}:\uffff`
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async deleteDocument(id: string): Promise<boolean> {
|
|
||||||
const docToDelete = await this.getDocument(id)
|
|
||||||
return this.GetDB()
|
|
||||||
.then((db) => db.remove(docToDelete))
|
|
||||||
.then((result) => {
|
|
||||||
return result.ok
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Sync private/protected methods
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
/**
|
|
||||||
* Try to get PouchDB database using session information
|
|
||||||
* This method is overridden in PouchDB Service, as session information is inaccessible in web-worker
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public async GetSessionDB(): Promise<PouchDB.Database> {
|
|
||||||
if(this.pouchDb){
|
|
||||||
console.log("Session DB already exists..")
|
|
||||||
return this.pouchDb
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!this.current_user){
|
|
||||||
throw new Error("current user is required when initializing pouchdb within web-worker")
|
|
||||||
}
|
|
||||||
if(!this._auth_token){
|
|
||||||
throw new Error("auth token is required when initializing pouchdb within web-worker")
|
|
||||||
}
|
|
||||||
let auth_token = this._auth_token
|
|
||||||
|
|
||||||
// add JWT bearer token header to all requests
|
|
||||||
// https://stackoverflow.com/questions/62129654/how-to-handle-jwt-authentication-with-rxdb
|
|
||||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(this.current_user), {
|
|
||||||
skip_setup: true,
|
|
||||||
fetch: function (url, opts) {
|
|
||||||
opts.headers.set('Authorization', `Bearer ${auth_token}`)
|
|
||||||
return PouchDB.fetch(url, opts);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return this.pouchDb
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getRemoteUserDb(username: string){
|
|
||||||
return `${this.remotePouchEndpoint}/userdb-${this.toHex(username)}`
|
|
||||||
}
|
|
||||||
// protected enableSync(userIdentifier: string){
|
|
||||||
// this.replicationHandler = this.localPouchDb.sync(this.getRemoteUserDb(userIdentifier), {live: true, retry: true})
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
private toHex(s: string) {
|
|
||||||
// utf8 to latin1
|
|
||||||
s = unescape(encodeURIComponent(s))
|
|
||||||
let h = ''
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
h += s.charCodeAt(i).toString(16)
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
|
||||||
import {SourceType} from '../database/source_types';
|
|
||||||
|
|
||||||
export class ClientConfig {
|
|
||||||
fasten_api_endpoint_base: string
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
import {DocType} from '../../database/constants';
|
|
||||||
import {IResourceRaw} from '../../conduit/interface';
|
|
||||||
import {Base64} from '../../utils/base64';
|
|
||||||
|
|
||||||
export class ResourceFhir {
|
|
||||||
_id?: string
|
|
||||||
_rev?: string
|
|
||||||
doc_type: DocType = DocType.ResourceFhir
|
|
||||||
updated_at?: string
|
|
||||||
|
|
||||||
fhir_version: string = ""
|
|
||||||
source_id: string = ""
|
|
||||||
source_resource_type: string = ""
|
|
||||||
source_resource_id: string = ""
|
|
||||||
|
|
||||||
resource_raw?: IResourceRaw
|
|
||||||
|
|
||||||
constructor(object?: any) {
|
|
||||||
if(object){
|
|
||||||
object.doc_type = DocType.ResourceFhir
|
|
||||||
return Object.assign(this, object)
|
|
||||||
} else{
|
|
||||||
this.doc_type = DocType.ResourceFhir
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
populateId(){
|
|
||||||
//TODO: source_id should be base64 encoded (otherwise we get nested : chars)
|
|
||||||
this._id = `${this.doc_type}:${Base64.Encode(this.source_id)}:${this.source_resource_type}:${this.source_resource_id}`
|
|
||||||
}
|
|
||||||
base64Id(): string {
|
|
||||||
this.populateId()
|
|
||||||
return Base64.Encode(this._id)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import {LighthouseSourceMetadata} from '../lighthouse/lighthouse-source-metadata';
|
|
||||||
import {SourceType} from './source_types';
|
|
||||||
import {DocType} from '../../../lib/database/constants';
|
|
||||||
import {Base64} from '../../utils/base64';
|
|
||||||
|
|
||||||
export class Source extends LighthouseSourceMetadata{
|
|
||||||
_id?: string
|
|
||||||
_rev?: string
|
|
||||||
doc_type: string
|
|
||||||
updated_at?: string
|
|
||||||
|
|
||||||
source_type: SourceType
|
|
||||||
patient: string
|
|
||||||
access_token: string
|
|
||||||
refresh_token?: string
|
|
||||||
id_token?: string
|
|
||||||
expires_at: number //seconds since epoch
|
|
||||||
|
|
||||||
constructor(object: any) {
|
|
||||||
super()
|
|
||||||
object.doc_type = DocType.Source
|
|
||||||
return Object.assign(this, object)
|
|
||||||
}
|
|
||||||
|
|
||||||
populateId(){
|
|
||||||
this._id = `${this.doc_type}:${this.source_type}:${this.patient}`
|
|
||||||
}
|
|
||||||
base64Id(): string {
|
|
||||||
this.populateId()
|
|
||||||
return Base64.Encode(this._id)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,50 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
export enum SourceType {
|
|
||||||
Manual = "manual",
|
|
||||||
|
|
||||||
Athena = "athena",
|
|
||||||
HealthIT = "healthit",
|
|
||||||
Logica = "logica",
|
|
||||||
|
|
||||||
//platforms
|
|
||||||
CareEvolution = "careevolution",
|
|
||||||
Cerner = "cerner",
|
|
||||||
Epic = "epic",
|
|
||||||
|
|
||||||
//providers
|
|
||||||
Aetna = "aetna",
|
|
||||||
Amerigroup = "amerigroup",
|
|
||||||
AmerigroupMedicaid = "amerigroupmedicaid",
|
|
||||||
Anthem = "anthem",
|
|
||||||
AnthemBluecrossCA = "anthembluecrossca",
|
|
||||||
BlueButton = "bluebutton",
|
|
||||||
BluecrossBlueshieldKansasMedicare = "bcbskansasmedicare",
|
|
||||||
BluecrossBlueshieldKansas = "bcbskansas",
|
|
||||||
BluecrossBlueshieldNY = "bcbsny",
|
|
||||||
BlueMedicareAdvantage = "bluemedicareadvantage",
|
|
||||||
ClearHealthAlliance = "clearhealthalliance",
|
|
||||||
Cigna = "cigna",
|
|
||||||
DellChildrens = "dellchildrens",
|
|
||||||
EmpireBlue = "empireblue",
|
|
||||||
EmpireBlueMedicaid = "empirebluemedicaid",
|
|
||||||
HealthyBlueLA = "healthybluela",
|
|
||||||
HealthyBlueLAMedicaid = "healthybluelamedicaid",
|
|
||||||
HealthyBlueMO = "healthybluemo",
|
|
||||||
HealthyBlueMOMedicaid = "healthybluemomedicaid",
|
|
||||||
HealthyBlueNC = "healthybluenc",
|
|
||||||
HealthyBlueNCMedicaid = "healthybluencmedicaid",
|
|
||||||
HealthyBlueNE = "healthybluene",
|
|
||||||
HealthyBlueSC = "healthybluesc",
|
|
||||||
HighmarkWesternNY = "highmarkwesternny",
|
|
||||||
SimplyHealthcareMedicaid = "simplyhealthcaremedicaid",
|
|
||||||
SimplyHealthcareMedicare = "simplyhealthcaremedicare",
|
|
||||||
SummitCommunityCare = "summitcommunitycare",
|
|
||||||
UCSF = "ucsf-health",
|
|
||||||
Unicare = "unicare",
|
|
||||||
UnicareMA = "unicarema",
|
|
||||||
UnicareMedicaid = "unicaremedicaid",
|
|
||||||
Humana = "humana",
|
|
||||||
Kaiser = "kaiser",
|
|
||||||
UnitedHealthcare = "unitedhealthcare",
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue