provide a consistent way to retrun results from WebWorkers.

fixed Username field in login page.
added support for CORS relay
fixed spec files.
This commit is contained in:
Jason Kulatunga 2022-10-12 18:47:12 -07:00
parent 6af72266f6
commit 4f90a9eedb
23 changed files with 214 additions and 118 deletions

View File

@ -23,3 +23,9 @@ docker run --rm -it -p 5984:5984 -v `pwd`/.couchdb/data:/opt/couchdb/data -v `pw
- WebUI: - WebUI:
- username: `testuser` - username: `testuser`
- password: `testuser` - password: `testuser`
# Running tests
- ng test --include='**/base_client.spec.ts'
- ng test --include='lib/**/*.spec.ts'

View File

@ -6,6 +6,4 @@ Find & replace the following
- `fastenhealth` - find and replace this with your binary name - `fastenhealth` - find and replace this with your binary name
- make sure you rename the folder as well. - make sure you rename the folder as well.
# Running tests
- ng test --include='**/base_client.spec.ts'

View File

@ -0,0 +1,52 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
//TODO, there are security implications to this, we need to make sure we lock this down.
func CORSProxy(c *gin.Context) {
//appConfig := c.MustGet("CONFIG").(config.Interface)
corsUrl := fmt.Sprintf("https://%s", strings.TrimPrefix(c.Param("proxyPath"), "/"))
remote, err := url.Parse(corsUrl)
remote.RawQuery = c.Request.URL.Query().Encode()
if err != nil {
panic(err)
}
proxy := httputil.ReverseProxy{}
//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 = remote.Path
//TODO: throw an error if the remote.Host is not allowed
}
proxy.ModifyResponse = func(r *http.Response) error {
//b, _ := ioutil.ReadAll(r.Body)
//buf := bytes.NewBufferString("Monkey")
//buf.Write(b)
//r.Body = ioutil.NopCloser(buf)
r.Header.Set("Access-Control-Allow-Methods", "GET,HEAD")
r.Header.Set("Access-Control-Allow-Credentials", "true")
r.Header.Set("Access-Control-Allow-Origin", "*")
return nil
}
proxy.ServeHTTP(c.Writer, c.Request)
}

View File

@ -44,6 +44,8 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
api.GET("/metadata/source", handler.GetMetadataSource) api.GET("/metadata/source", handler.GetMetadataSource)
r.Any("/database/*proxyPath", handler.CouchDBProxy) r.Any("/database/*proxyPath", handler.CouchDBProxy)
r.GET("/cors/*proxyPath", handler.CORSProxy)
r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
} }
} }

View File

@ -4,4 +4,6 @@ export class SourceSyncMessage {
source: Source source: Source
userIdentifier: string userIdentifier: string
encryptionKey?: string encryptionKey?: string
response?: any
} }

View File

@ -7,18 +7,15 @@
<form (ngSubmit)="signinSubmit()" #userForm="ngForm"> <form (ngSubmit)="signinSubmit()" #userForm="ngForm">
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>Username</label>
<input [(ngModel)]="existingUser.username" name="username" #username="ngModel" required minlength="2" type="text" class="form-control" placeholder="Enter your email"> <input [(ngModel)]="existingUser.username" name="username" #username="ngModel" required minlength="2" type="text" class="form-control" placeholder="Enter your username">
<div *ngIf="username.invalid && (username.dirty || username.touched)" class="alert alert-danger"> <div *ngIf="username.invalid && (username.dirty || username.touched)" class="alert alert-danger">
<div *ngIf="username.errors?.['required']"> <div *ngIf="username.errors?.['required']">
Email is required. Username is required.
</div> </div>
<div *ngIf="username.errors?.['minlength']"> <div *ngIf="username.errors?.['minlength']">
Email must be at least 4 characters long. Username must be at least 4 characters long.
</div>
<div *ngIf="username.errors?.['email']">
Email is not a valid email address.
</div> </div>
</div> </div>
</div><!-- form-group --> </div><!-- form-group -->

View File

@ -14,6 +14,8 @@ import {SourceType} from '../../../lib/models/database/source_types';
import {QueueService} from '../../workers/queue.service'; 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 {UpsertSummary} from '../../../lib/models/fasten/upsert-summary';
// If you dont import this angular will import the wrong "Location" // If you dont import this angular will import the wrong "Location"
export const sourceConnectWindowTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120) export const sourceConnectWindowTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120)
@ -192,7 +194,7 @@ export class MedicalSourcesComponent implements OnInit {
expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())), expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())),
}) })
await this.fastenDb.CreateSource(dbSourceCredential).then(console.log) await this.fastenDb.UpsertSource(dbSourceCredential).then(console.log)
this.queueSourceSyncWorker(sourceType as SourceType, dbSourceCredential) this.queueSourceSyncWorker(sourceType as SourceType, dbSourceCredential)
}) })
@ -245,29 +247,40 @@ export class MedicalSourcesComponent implements OnInit {
// so that we can show incompelte statuses // so that we can show incompelte statuses
this.queueService.runSourceSyncWorker(source) this.queueService.runSourceSyncWorker(source)
.subscribe((msg) => { .subscribe((msg) => {
const sourceSyncMessage = JSON.parse(msg) const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
delete this.status[sourceType] delete this.status[sourceType]
// window.location.reload(); // window.location.reload();
console.log("source sync-all response:", sourceSyncMessage) console.log("source sync-all response:", sourceSyncMessage)
//remove item from available sources list, add to connected sources. //remove item from available sources list, add to connected sources.
this.availableSourceList.splice(this.availableSourceList.findIndex((item) => item.metadata.source_type == sourceType), 1); this.availableSourceList.splice(this.availableSourceList.findIndex((item) => item.metadata.source_type == sourceType), 1);
this.connectedSourceList.push({source: sourceSyncMessage.source, metadata: this.metadataSources[sourceType]}) 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 toastNotificaiton = new ToastNotification() const toastNotification = new ToastNotification()
toastNotificaiton.type = ToastType.Success toastNotification.type = ToastType.Success
toastNotificaiton.message = `Successfully connected ${sourceType}` toastNotification.message = `Successfully connected ${sourceType}`
this.toastService.show(toastNotificaiton)
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) => { (err) => {
delete this.status[sourceType] delete this.status[sourceType]
// window.location.reload(); // window.location.reload();
const toastNotificaiton = new ToastNotification() const toastNotification = new ToastNotification()
toastNotificaiton.type = ToastType.Error toastNotification.type = ToastType.Error
toastNotificaiton.message = `An error occurred while accessing ${sourceType}: ${err}` toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}`
toastNotificaiton.autohide = false toastNotification.autohide = false
this.toastService.show(toastNotificaiton) this.toastService.show(toastNotification)
console.error(err) console.error(err)
}); });
} }

View File

@ -21,13 +21,14 @@ export class SourceSyncWorker implements DoWork<string, string> {
const client = NewClient(sourceSyncMessage.source.source_type, sourceSyncMessage.source) const client = NewClient(sourceSyncMessage.source.source_type, sourceSyncMessage.source)
//TODO: validate the FHIR version from the datasource matches the client //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. // if the source token has been refreshed, we need to store it in the DB.
// await db.CreateSource() // await db.UpsertSource()
console.log("!!!!!!!!!!!!!!STARTING WORKER SYNC!!!!!!!!!", sourceSyncMessage) console.log("!!!!!!!!!!!!!!STARTING WORKER SYNC!!!!!!!!!", sourceSyncMessage)
return client.SyncAll(db) return client.SyncAll(db)
.then((resp) => { .then((resp) => {
console.log("!!!!!!!!!!!!!COMPLETE WORKER SYNC!!!!!!!!!!", resp) console.log("!!!!!!!!!!!!!COMPLETE WORKER SYNC!!!!!!!!!!", resp)
return JSON.stringify(resp) sourceSyncMessage.response = resp
return JSON.stringify(sourceSyncMessage)
}) })
.catch((err) => { .catch((err) => {

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class AetnaClient extends FHIR401Client implements IClient { export class AetnaClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -13,11 +14,11 @@ export class AetnaClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const bundle = await this.GetResourceBundlePaginated("Patient") const bundle = await this.GetResourceBundlePaginated("Patient")
const wrappedResourceModels = await this.ProcessBundle(bundle) const wrappedResourceModels = await this.ProcessBundle(bundle)
//todo, create the resources in dependency order //todo, create the resources in dependency order
return await db.CreateResources(wrappedResourceModels) return await db.UpsertResources(wrappedResourceModels)
} }
} }

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class AthenaClient extends FHIR401Client implements IClient { export class AthenaClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -13,7 +14,7 @@ export class AthenaClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const supportedResources: string[] = [ const supportedResources: string[] = [
"AllergyIntolerance", "AllergyIntolerance",
//"Binary", //"Binary",

View File

@ -55,6 +55,13 @@ export abstract class BaseClient {
} else { } else {
resourceUrl = resourceSubpathOrNext 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 = this.getCORSProxyPath() + `${resourceParts.hostname}${resourceParts.pathname}${resourceParts.search}`
}
//refresh the source if required //refresh the source if required
this.source = await this.refreshExpiredTokenIfRequired(this.source) this.source = await this.refreshExpiredTokenIfRequired(this.source)
@ -80,6 +87,12 @@ export abstract class BaseClient {
// Private methods // Private methods
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
private getCORSProxyPath(): string {
const basePath = globalThis.location.pathname.split('/web').slice(0, 1)[0];
return `${globalThis.location.origin}${basePath || '/'}cors/`
}
private async refreshExpiredTokenIfRequired(source: Source): Promise<Source> { private async refreshExpiredTokenIfRequired(source: Source): Promise<Source> {
//check if token has expired, and a refreshtoken is available //check if token has expired, and a refreshtoken is available
// Note: source.expires_at is in seconds, Date.now() is in milliseconds. // Note: source.expires_at is in seconds, Date.now() is in milliseconds.

View File

@ -4,9 +4,11 @@ import {IResourceBundleRaw} from '../../interface';
import {ResourceFhir} from '../../../models/database/resource_fhir'; import {ResourceFhir} from '../../../models/database/resource_fhir';
import {NewRepositiory} from '../../../database/pouchdb_repository'; import {NewRepositiory} from '../../../database/pouchdb_repository';
import {Base64} from '../../../utils/base64'; import {Base64} from '../../../utils/base64';
import * as PouchDB from 'pouchdb/dist/pouchdb';
// @ts-ignore // @ts-ignore
import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json'; import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json';
import {IDatabaseRepository} from '../../../database/interface';
class TestClient extends FHIR401Client { class TestClient extends FHIR401Client {
constructor(source: Source) { constructor(source: Source) {
@ -59,23 +61,36 @@ describe('FHIR401Client', () => {
}) })
describe('SyncAll', () => { describe('SyncAll', () => {
let repository: IDatabaseRepository;
beforeEach(async () => {
repository = NewRepositiory(null, null, new PouchDB("FHIR401Client-testing"));
});
afterEach(async () => {
if(repository){
const db = await repository.GetDB()
db.destroy() //wipe the db.
}
})
it('should correctly add resources to the database', async () => { it('should correctly add resources to the database', async () => {
//setup //setup
let response = new Response(JSON.stringify(FHIR401Client_ProcessBundle)); let response = new Response(JSON.stringify(FHIR401Client_ProcessBundle));
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}/$everything`}); Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}/$everything`});
spyOn(window, "fetch").and.returnValue(Promise.resolve(response)); spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
const db = NewRepositiory("fastentest")
//test //test
const resp = await client.SyncAll(db) const resp = await client.SyncAll(repository)
const firstResourceFhir = resp[0] const firstResourceFhir = resp[0]
const resourceIdParts = firstResourceFhir.split(":") const resourceIdParts = firstResourceFhir.split(":")
//expect //expect
expect(resp.length).toEqual(206); expect(resp.totalResources).toEqual(206);
expect(resp.updatedResources).toEqual([]);
expect(firstResourceFhir).toEqual('resource_fhir:c291cmNlOmFldG5hOjEyMzQ1:Patient:c088b7af-fc41-43cc-ab80-4a9ab8d47cd9'); expect(firstResourceFhir).toEqual('resource_fhir:c291cmNlOmFldG5hOjEyMzQ1:Patient:c088b7af-fc41-43cc-ab80-4a9ab8d47cd9');
expect(Base64.Decode(resourceIdParts[1])).toEqual("source:aetna:12345"); expect(Base64.Decode(resourceIdParts[1])).toEqual("source:aetna:12345");
}); });
}) })

View File

@ -3,6 +3,7 @@ import {BaseClient} from './base_client';
import {Source} from '../../../models/database/source'; import {Source} from '../../../models/database/source';
import {IDatabaseRepository} from '../../../database/interface'; import {IDatabaseRepository} from '../../../database/interface';
import {ResourceFhir} from '../../../models/database/resource_fhir'; import {ResourceFhir} from '../../../models/database/resource_fhir';
import {UpsertSummary} from '../../../models/fasten/upsert-summary';
export class FHIR401Client extends BaseClient implements IClient { export class FHIR401Client extends BaseClient implements IClient {
@ -18,13 +19,13 @@ export class FHIR401Client extends BaseClient implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
public async SyncAll(db: IDatabaseRepository): Promise<string[]> { public async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const bundle = await this.GetPatientBundle(this.source.patient) const bundle = await this.GetPatientBundle(this.source.patient)
const wrappedResourceModels = await this.ProcessBundle(bundle) const wrappedResourceModels = await this.ProcessBundle(bundle)
//todo, create the resources in dependency order //todo, create the resources in dependency order
return db.CreateResources(wrappedResourceModels) return db.UpsertResources(wrappedResourceModels)
} }
@ -35,7 +36,7 @@ export class FHIR401Client extends BaseClient implements IClient {
* @param resourceNames * @param resourceNames
* @constructor * @constructor
*/ */
public async SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<string[]>{ public async SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>{
//Store the Patient //Store the Patient
const patientResource = await this.GetPatient(this.source.patient) const patientResource = await this.GetPatient(this.source.patient)
@ -45,7 +46,7 @@ export class FHIR401Client extends BaseClient implements IClient {
patientResourceFhir.source_resource_id = patientResource.id patientResourceFhir.source_resource_id = patientResource.id
patientResourceFhir.resource_raw = patientResource patientResourceFhir.resource_raw = patientResource
await db.CreateResource(patientResourceFhir) const upsertSummary = await db.UpsertResource(patientResourceFhir)
//error map storage. //error map storage.
let syncErrors = {} let syncErrors = {}
@ -56,7 +57,9 @@ export class FHIR401Client extends BaseClient implements IClient {
try { try {
let bundle = await this.GetResourceBundlePaginated(`${resourceType}?patient=${this.source.patient}`) let bundle = await this.GetResourceBundlePaginated(`${resourceType}?patient=${this.source.patient}`)
let wrappedResourceModels = await this.ProcessBundle(bundle) let wrappedResourceModels = await this.ProcessBundle(bundle)
await db.CreateResources(wrappedResourceModels) let resourceUpsertSummary = await db.UpsertResources(wrappedResourceModels)
upsertSummary.updatedResources = upsertSummary.updatedResources.concat(resourceUpsertSummary.updatedResources)
upsertSummary.totalResources += resourceUpsertSummary.totalResources
} }
catch (e) { catch (e) {
console.error(`An error occurred while processing ${resourceType} bundle ${this.source.patient}`) console.error(`An error occurred while processing ${resourceType} bundle ${this.source.patient}`)
@ -66,7 +69,7 @@ export class FHIR401Client extends BaseClient implements IClient {
} }
//TODO: correctly return newly inserted documents //TODO: correctly return newly inserted documents
return [] return upsertSummary
} }
/** /**
@ -75,7 +78,7 @@ export class FHIR401Client extends BaseClient implements IClient {
* @param bundleFile * @param bundleFile
* @constructor * @constructor
*/ */
public async SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<any> { public async SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary> {
return Promise.reject(new Error("not implemented")); return Promise.reject(new Error("not implemented"));
} }

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class BlueButtonClient extends FHIR401Client implements IClient { export class BlueButtonClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -13,7 +14,7 @@ export class BlueButtonClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const supportedResources: string[] = [ const supportedResources: string[] = [
"ExplanationOfBenefit", "ExplanationOfBenefit",
"Coverage", "Coverage",

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class CernerClient extends FHIR401Client implements IClient { export class CernerClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -15,7 +16,7 @@ export class CernerClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const supportedResources: string[] = [ const supportedResources: string[] = [
"AllergyIntolerance", "AllergyIntolerance",
"CarePlan", "CarePlan",

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class EpicClient extends FHIR401Client implements IClient { export class EpicClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -15,7 +16,7 @@ export class EpicClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const supportedResources: string[] = [ const supportedResources: string[] = [
"AllergyIntolerance", "AllergyIntolerance",
"CarePlan", "CarePlan",

View File

@ -2,6 +2,7 @@ import {IClient} from '../interface';
import {FHIR401Client} from './base/fhir401_r4_client'; import {FHIR401Client} from './base/fhir401_r4_client';
import {Source} from '../../models/database/source'; import {Source} from '../../models/database/source';
import {IDatabaseRepository} from '../../database/interface'; import {IDatabaseRepository} from '../../database/interface';
import {UpsertSummary} from '../../models/fasten/upsert-summary';
export class HealthITClient extends FHIR401Client implements IClient { export class HealthITClient extends FHIR401Client implements IClient {
constructor(source: Source) { constructor(source: Source) {
@ -15,7 +16,7 @@ export class HealthITClient extends FHIR401Client implements IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
async SyncAll(db: IDatabaseRepository): Promise<string[]> { async SyncAll(db: IDatabaseRepository): Promise<UpsertSummary> {
const supportedResources: string[] = [ const supportedResources: string[] = [
"AllergyIntolerance", "AllergyIntolerance",
"CarePlan", "CarePlan",

View File

@ -1,4 +1,5 @@
import {IDatabaseRepository} from '../database/interface'; import {IDatabaseRepository} from '../database/interface';
import {UpsertSummary} from '../models/fasten/upsert-summary';
export interface IClient { export interface IClient {
fhirVersion: string fhirVersion: string
@ -10,13 +11,13 @@ export interface IClient {
* @param db * @param db
* @constructor * @constructor
*/ */
SyncAll(db: IDatabaseRepository): Promise<any> SyncAll(db: IDatabaseRepository): Promise<UpsertSummary>
SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<string[]> SyncAllByResourceName(db: IDatabaseRepository, resourceNames: string[]): Promise<UpsertSummary>
//Manual client ONLY functions //Manual client ONLY functions
SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<any> SyncAllFromBundleFile(db: IDatabaseRepository, bundleFile: any): Promise<UpsertSummary>
} }
//This is the "raw" Fhir resource //This is the "raw" Fhir resource

View File

@ -3,6 +3,7 @@ import {ResourceFhir} from '../models/database/resource_fhir';
import {SourceSummary} from '../models/fasten/source-summary'; import {SourceSummary} from '../models/fasten/source-summary';
import {Summary} from '../models/fasten/summary'; import {Summary} from '../models/fasten/summary';
import {User} from '../models/fasten/user'; import {User} from '../models/fasten/user';
import {UpsertSummary} from '../models/fasten/upsert-summary';
// import {SourceSummary} from '../../app/models/fasten/source-summary'; // import {SourceSummary} from '../../app/models/fasten/source-summary';
export interface IDatabaseDocument { export interface IDatabaseDocument {
@ -28,7 +29,7 @@ export interface IDatabaseRepository {
// GetUserByEmail(context.Context, string) (*models.User, error) // GetUserByEmail(context.Context, string) (*models.User, error)
// GetCurrentUser(context.Context) *models.User // GetCurrentUser(context.Context) *models.User
CreateSource(source: Source): Promise<string> UpsertSource(source: Source): Promise<UpsertSummary>
GetSource(source_id: string): Promise<Source> GetSource(source_id: string): Promise<Source>
DeleteSource(source_id: string): Promise<boolean> DeleteSource(source_id: string): Promise<boolean>
GetSourceSummary(source_id: string): Promise<SourceSummary> GetSourceSummary(source_id: string): Promise<SourceSummary>
@ -40,8 +41,8 @@ export interface IDatabaseRepository {
// GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error) // GetResourceBySourceId(context.Context, string, string) (*models.ResourceFhir, error)
// ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error) // ListResources(context.Context, models.ListResourceQueryOptions) ([]models.ResourceFhir, error)
// GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error) // GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
CreateResource(resource: ResourceFhir): Promise<string> UpsertResource(resource: ResourceFhir): Promise<UpsertSummary>
CreateResources(resources: ResourceFhir[]): Promise<string[]> UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary>
GetResource(resource_id: string): Promise<ResourceFhir> GetResource(resource_id: string): Promise<ResourceFhir>
GetResources(): Promise<IDatabasePaginatedResponse> GetResources(): Promise<IDatabasePaginatedResponse>
GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse> GetResourcesForSource(source_id: string, source_resource_type?: string): Promise<IDatabasePaginatedResponse>

View File

@ -3,17 +3,19 @@ import {NewRepositiory} from './pouchdb_repository';
import {SourceType} from '../models/database/source_types'; import {SourceType} from '../models/database/source_types';
import {Source} from '../models/database/source'; import {Source} from '../models/database/source';
import {DocType} from './constants'; import {DocType} from './constants';
import * as PouchDB from 'pouchdb/dist/pouchdb';
describe('PouchdbRepository', () => { describe('PouchdbRepository', () => {
let repository: IDatabaseRepository; let repository: IDatabaseRepository;
beforeEach(async () => { beforeEach(async () => {
repository = NewRepositiory(); repository = NewRepositiory(null, null, new PouchDB("PouchdbRepository-testing"));
}); });
afterEach(async () => { afterEach(async () => {
if(repository){ if(repository){
await repository.GetDB().destroy() //wipe the db. const db = await repository.GetDB()
db.destroy() //wipe the db.
} }
}) })
@ -24,24 +26,25 @@ describe('PouchdbRepository', () => {
describe('CreateSource', () => { describe('CreateSource', () => {
it('should return an id', async () => { it('should return an id', async () => {
const createdId = await repository.CreateSource(new Source({ const createdId = await repository.UpsertSource(new Source({
patient: 'patient', patient: 'patient',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
})) }))
expect(createdId).toEqual("source:aetna:patient"); expect(createdId.totalResources).toEqual(1);
expect(createdId.updatedResources[0]).toEqual("source:aetna:patient");
}); });
}) })
describe('GetSource', () => { describe('GetSource', () => {
it('should return an source', async () => { it('should return an source', async () => {
const createdId = await repository.CreateSource(new Source({ const createdResource = await repository.UpsertSource(new Source({
patient: 'patient', patient: 'patient',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
access_token: 'hello-world', access_token: 'hello-world',
})) }))
const createdSource = await repository.GetSource(createdId) const createdSource = await repository.GetSource(createdResource.updatedResources[0])
expect(createdSource.doc_type).toEqual(DocType.Source); expect(createdSource.doc_type).toEqual(DocType.Source);
expect(createdSource.patient).toEqual('patient'); expect(createdSource.patient).toEqual('patient');
@ -52,13 +55,13 @@ describe('PouchdbRepository', () => {
describe('DeleteSource', () => { describe('DeleteSource', () => {
it('should delete a source', async () => { it('should delete a source', async () => {
const createdId = await repository.CreateSource(new Source({ const createdResource = await repository.UpsertSource(new Source({
patient: 'patient-to-delete', patient: 'patient-to-delete',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
access_token: 'hello-world', access_token: 'hello-world',
})) }))
console.log(createdId) console.log(createdResource)
const deletedSource = await repository.DeleteSource(createdId) const deletedSource = await repository.DeleteSource(createdResource.updatedResources[0])
expect(deletedSource).toBeTruthy(); expect(deletedSource).toBeTruthy();
}); });
@ -66,19 +69,19 @@ describe('PouchdbRepository', () => {
describe('GetSources', () => { describe('GetSources', () => {
it('should return a list of sources', async () => { it('should return a list of sources', async () => {
await repository.CreateSource(new Source({ await repository.UpsertSource(new Source({
patient: 'patient1', patient: 'patient1',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
access_token: 'hello-world1', access_token: 'hello-world1',
})) }))
await repository.CreateSource(new Source({ await repository.UpsertSource(new Source({
patient: 'patient2', patient: 'patient2',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
access_token: 'hello-world2', access_token: 'hello-world2',
})) }))
await repository.CreateSource(new Source({ await repository.UpsertSource(new Source({
patient: 'patient3', patient: 'patient3',
source_type: SourceType.Aetna, source_type: SourceType.Aetna,
access_token: 'hello-world3', access_token: 'hello-world3',

View File

@ -9,6 +9,7 @@ import {Base64} from '../utils/base64';
import * as PouchDB from 'pouchdb/dist/pouchdb'; import * as PouchDB from 'pouchdb/dist/pouchdb';
import * as PouchCrypto from 'crypto-pouch'; import * as PouchCrypto from 'crypto-pouch';
import {PouchdbUpsert} from './plugins/upsert'; import {PouchdbUpsert} from './plugins/upsert';
import {UpsertSummary} from '../models/fasten/upsert-summary';
PouchDB.plugin(PouchCrypto); PouchDB.plugin(PouchCrypto);
@ -42,8 +43,8 @@ PouchDB.plugin(PouchCrypto);
* Eventually this method should dyanmically dtermine the version of the repo to return from the env. * Eventually this method should dyanmically dtermine the version of the repo to return from the env.
* @constructor * @constructor
*/ */
export function NewRepositiory(userIdentifier?: string, encryptionKey?: string): IDatabaseRepository { export function NewRepositiory(userIdentifier?: string, encryptionKey?: string, localPouchDb?: PouchDB.Database): IDatabaseRepository {
return new PouchdbRepository(userIdentifier, encryptionKey) return new PouchdbRepository(userIdentifier, encryptionKey, localPouchDb)
} }
export class PouchdbRepository implements IDatabaseRepository { export class PouchdbRepository implements IDatabaseRepository {
@ -60,7 +61,7 @@ export class PouchdbRepository implements IDatabaseRepository {
* @param userIdentifier * @param userIdentifier
* @param encryptionKey * @param encryptionKey
*/ */
constructor(userIdentifier?: string, encryptionKey?: string) { constructor(userIdentifier?: string, encryptionKey?: string, localPouchDb?: PouchDB.Database) {
this.remotePouchEndpoint = `${globalThis.location.protocol}//${globalThis.location.host}${this.getBasePath()}/database` this.remotePouchEndpoint = `${globalThis.location.protocol}//${globalThis.location.host}${this.getBasePath()}/database`
//setup PouchDB Plugins //setup PouchDB Plugins
@ -71,6 +72,10 @@ export class PouchdbRepository implements IDatabaseRepository {
this.encryptionKey = encryptionKey this.encryptionKey = encryptionKey
// this.enableSync(userIdentifier) // this.enableSync(userIdentifier)
} }
if(localPouchDb){
console.warn("using local pouchdb, this should only be used for testing")
this.pouchDb = localPouchDb
}
} }
@ -96,7 +101,7 @@ export class PouchdbRepository implements IDatabaseRepository {
/////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////
// Source // Source
public async CreateSource(source: Source): Promise<string> { public async UpsertSource(source: Source): Promise<UpsertSummary> {
return this.upsertDocument(source); return this.upsertDocument(source);
} }
@ -156,11 +161,11 @@ export class PouchdbRepository implements IDatabaseRepository {
/////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////
// Resource // Resource
public async CreateResource(resource: ResourceFhir): Promise<string> { public async UpsertResource(resource: ResourceFhir): Promise<UpsertSummary> {
return this.upsertDocument(resource); return this.upsertDocument(resource);
} }
public async CreateResources(resources: ResourceFhir[]): Promise<string[]> { public async UpsertResources(resources: ResourceFhir[]): Promise<UpsertSummary> {
return this.upsertBulk(resources); return this.upsertBulk(resources);
} }
@ -228,7 +233,7 @@ export class PouchdbRepository implements IDatabaseRepository {
// update/insert a new document. Returns a promise of the generated id. // update/insert a new document. Returns a promise of the generated id.
protected upsertDocument(newDoc: IDatabaseDocument) : Promise<string> { protected upsertDocument(newDoc: IDatabaseDocument) : Promise<UpsertSummary> {
// make sure we always "populate" the ID for every document before submitting // make sure we always "populate" the ID for every document before submitting
newDoc.populateId() newDoc.populateId()
@ -285,22 +290,29 @@ export class PouchdbRepository implements IDatabaseRepository {
}) })
}) })
.then(( result ): string => { .then(( result ): UpsertSummary => {
return( result.id ); // // 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 upsertBulk(docs: IDatabaseDocument[]): Promise<string[]> { protected upsertBulk(docs: IDatabaseDocument[]): Promise<UpsertSummary> {
return this.GetDB() return Promise.all(docs.map((doc) => {
.then((db) => { doc.populateId();
return this.upsertDocument(doc)
return Promise.all(docs.map((doc) => { })).then((results) => {
doc.populateId(); return results.reduce((prev, current ) => {
return this.upsertDocument(doc) prev.totalResources += current.totalResources
})) prev.updatedResources = prev.updatedResources.concat(current.updatedResources)
return prev
}) }, new UpsertSummary())
})
} }
protected getDocument(id: string): Promise<any> { protected getDocument(id: string): Promise<any> {
@ -333,44 +345,6 @@ export class PouchdbRepository implements IDatabaseRepository {
}) })
} }
//DEPRECATED
/**
* create multiple documents, returns a list of generated ids
* @deprecated
* @param docs
* @protected
*/
protected createBulk(docs: IDatabaseDocument[]): Promise<string[]> {
return this.GetDB()
.then((db) => {
return db.bulkDocs(docs.map((doc) => { doc.populateId(); return doc }))
})
.then((results): string[] => {
return results.map((result) => result.id)
})
}
/**
* create a new document. Returns a promise of the generated id.
* @deprecated
* @param doc
* @protected
*/
protected createDocument(doc: IDatabaseDocument) : Promise<string> {
// make sure we always "populate" the ID for every document before submitting
doc.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) => db.put(doc))
.then(( result ): string => {
return( result.id );
}
);
}
/////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////
// Sync private/protected methods // Sync private/protected methods
/////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////

View File

@ -0,0 +1,8 @@
import {Source} from '../database/source';
import {ResourceFhir} from '../database/resource_fhir';
import {ResourceTypeCounts} from './source-summary';
export class UpsertSummary {
updatedResources: string[] = []
totalResources: number = 0
}

View File

@ -1,11 +1,12 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"skipLibCheck": true, //without this the "dom" and the "webworker" libs conflict
"resolveJsonModule": true, "resolveJsonModule": true,
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": [
"jasmine", "jasmine",
"node"
] ]
}, },
"files": [ "files": [