diff --git a/config.xml b/config.xml index fcc9b970c..7c1947527 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + RADAR Questionnaire An application that collects active data for research. RADAR-Base diff --git a/package-lock.json b/package-lock.json index 3fd20c613..88f7226dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "cordova-plugin-ionic-keyboard": "^2.2.0", "cordova-plugin-device": "^2.0.3", "tslib": "^2.5.0", + "pako": "^2.1.0", "@ionic-native/keyboard": "^5.26.0", "@ionic-native/insomnia": "^5.32.1", "@ionic-native/core": "^5.32.0", @@ -7443,9 +7444,9 @@ "deprecated": "Please see https://github.com/lydell/urix#deprecated" }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, "node_modules/merge-source-map/node_modules/source-map": { "version": "0.6.1", @@ -10660,6 +10661,12 @@ "node": ">=4.2.0" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "devOptional": true + }, "node_modules/ionic-mocks": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ionic-mocks/-/ionic-mocks-1.3.0.tgz", @@ -18080,6 +18087,11 @@ "inherits": "2.0.3" } }, + "node_modules/browserify-zlib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -26361,6 +26373,11 @@ "import-sort": "lib/index.js" } }, + "node_modules/hdr-histogram-js/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/stylefmt/node_modules/sugarss": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-0.2.0.tgz", @@ -33036,6 +33053,13 @@ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", "requires": { "pako": "~1.0.5" + }, + "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + } } }, "browserslist": { @@ -37970,6 +37994,13 @@ "@assemblyscript/loader": "^0.10.1", "base64-js": "^1.2.0", "pako": "^1.0.3" + }, + "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + } } }, "hdr-histogram-percentiles-obj": { @@ -39977,6 +40008,12 @@ "requires": { "immediate": "~3.0.5" } + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "devOptional": true } } }, @@ -41998,9 +42035,9 @@ } }, "pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, "parent-module": { "version": "1.0.1", diff --git a/package.json b/package.json index 8284cd67e..83604a484 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "radar-questionnaire", "description": "An application that collects active data for research.", - "version": "3.0.0", + "version": "3.2.0", "author": "RADAR Base", "homepage": "http://www.radar-base.org/", "scripts": { @@ -114,6 +114,7 @@ "moment-timezone": "^0.5.40", "morph-expressions": "^1.1.1", "ngx-moment": "^5.0.0", + "pako": "^2.1.0", "path": "^0.12.7", "phonegap-plugin-barcodescanner": "^8.1.0", "phonegap-plugin-mobile-accessibility": "^1.0.5", diff --git a/publishing_script.sh b/publishing_script.sh index aa099d67c..eed1e0c43 100755 --- a/publishing_script.sh +++ b/publishing_script.sh @@ -1,6 +1,5 @@ -ionic cordova build --release android +ionic cordova build android --release -- -- --packageType=apk -~/Library/Android/sdk/build-tools/28.0.3/zipalign -v 4 platforms/android/app/build/outputs/apk/release/app-release-unsigned.apk ~/Downloads/radar-armt-app-zipaligned.apk - -~/Library/Android/sdk/build-tools/28.0.3/apksigner sign -v --out ~/Downloads/radar-armt-app-$1.apk --ks ~/Downloads/radar-armt-release-key.keystore --ks-key-alias alias_name ~/Downloads/radar-armt-app-zipaligned.apk +~/Library/Android/sdk/build-tools/32.0.0/zipalign -v 4 platforms/android/app/build/outputs/apk/release/app-release-unsigned.apk ~/Downloads/radar-armt-app-zipaligned.apk +~/Library/Android/sdk/build-tools/32.0.0/apksigner sign -v --out ~/Downloads/radar-armt-app-$1.apk --ks ~/Downloads/radar-armt-release-key.keystore --ks-key-alias alias_name ~/Downloads/radar-armt-app-zipaligned.apk \ No newline at end of file diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 6d27a1245..7642677e0 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -14,6 +14,11 @@ const routes: Routes = [ m => m.ClinicalTasksModule ) }, + { + path: 'on-demand', + loadChildren: () => + import('./pages/on-demand/on-demand.module').then(m => m.OnDemandModule) + }, { path: 'settings', loadChildren: () => diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 798f0e11b..45f6b5e0f 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -41,6 +41,15 @@ import { RemoteConfigService } from './core/services/config/remote-config.service' import { SubjectConfigService } from './core/services/config/subject-config.service' +import { CacheService } from './core/services/kafka/cache.service' +import { AppEventConverterService } from './core/services/kafka/converters/app-event-converter.service' +import { AssessmentConverterService } from './core/services/kafka/converters/assessment-converter.service' +import { CompletionLogConverterService } from './core/services/kafka/converters/completion-log-converter.service' +import { ConverterFactoryService } from './core/services/kafka/converters/converter-factory.service.' +import { ConverterService } from './core/services/kafka/converters/converter.service' +import { HealthkitConverterService } from './core/services/kafka/converters/healthkit-converter.service' +import { KeyConverterService } from './core/services/kafka/converters/key-converter.service' +import { TimezoneConverterService } from './core/services/kafka/converters/timezone-converter.service' import { KafkaService } from './core/services/kafka/kafka.service' import { SchemaService } from './core/services/kafka/schema.service' import { AlertService } from './core/services/misc/alert.service' @@ -58,6 +67,8 @@ import { LocalScheduleService } from './core/services/schedule/local-schedule.se import { ScheduleFactoryService } from './core/services/schedule/schedule-factory.service' import { ScheduleGeneratorService } from './core/services/schedule/schedule-generator.service' import { ScheduleService } from './core/services/schedule/schedule.service' +import { GlobalStorageService } from './core/services/storage/global-storage.service' +import { HealthStorageService } from './core/services/storage/health-storage.service' import { StorageService } from './core/services/storage/storage.service' import { TokenService } from './core/services/token/token.service' import { AnalyticsService } from './core/services/usage/analytics.service' @@ -137,10 +148,20 @@ import { Utility } from './shared/utilities/util' KafkaService, LocalizationService, ScheduleGeneratorService, - StorageService, + GlobalStorageService, + HealthStorageService, + { provide: StorageService, useClass: GlobalStorageService }, TranslatePipe, UsageService, SchemaService, + ConverterFactoryService, + AssessmentConverterService, + AppEventConverterService, + CompletionLogConverterService, + TimezoneConverterService, + KeyConverterService, + HealthkitConverterService, + CacheService, NotificationGeneratorService, FcmRestNotificationService, LocalNotificationService, diff --git a/src/app/core/services/app-server/app-server.service.ts b/src/app/core/services/app-server/app-server.service.ts index f242db564..f0393f045 100644 --- a/src/app/core/services/app-server/app-server.service.ts +++ b/src/app/core/services/app-server/app-server.service.ts @@ -6,6 +6,8 @@ import { } from '@angular/common/http' import { Injectable } from '@angular/core' import * as moment from 'moment-timezone' +import { Subscription } from 'rxjs' +import { filter } from 'rxjs/operators' import * as urljoin from 'url-join' import { @@ -24,10 +26,8 @@ import { RemoteConfigService } from '../config/remote-config.service' import { SubjectConfigService } from '../config/subject-config.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' -import { StorageService } from '../storage/storage.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { TokenService } from '../token/token.service' -import { filter } from "rxjs/operators"; -import { Subscription } from "rxjs"; @Injectable() export class AppServerService { @@ -43,7 +43,7 @@ export class AppServerService { private tokenSubscription: Subscription = null constructor( - public storage: StorageService, + public storage: GlobalStorageService, public subjectConfig: SubjectConfigService, public logger: LogService, public remoteConfig: RemoteConfigService, @@ -66,24 +66,34 @@ export class AppServerService { ) .then(([subjectId, projectId, enrolmentDate, attributes, fcmToken]) => this.addProjectIfMissing(projectId) - .then(() => this.addSubjectIfMissing( + .then(() => + this.addSubjectIfMissing( subjectId, projectId, enrolmentDate, attributes, fcmToken ) - ).then(httpRes => { + ) + .then(httpRes => { if (this.tokenSubscription !== null) { - this.tokenSubscription.unsubscribe(); + this.tokenSubscription.unsubscribe() } - this.tokenSubscription = this.storage.observe(StorageKeys.FCM_TOKEN) + this.tokenSubscription = this.storage + .observe(StorageKeys.FCM_TOKEN) .pipe(filter(t => t && t !== fcmToken)) .subscribe(newFcmToken => - this.addSubjectIfMissing(subjectId, projectId, enrolmentDate, attributes, newFcmToken)) - return httpRes; + this.addSubjectIfMissing( + subjectId, + projectId, + enrolmentDate, + attributes, + newFcmToken + ) + ) + return httpRes }) - ); + ) } getHeaders() { diff --git a/src/app/core/services/config/app-config.service.spec.ts b/src/app/core/services/config/app-config.service.spec.ts index d51a8fbec..b55c98eec 100644 --- a/src/app/core/services/config/app-config.service.spec.ts +++ b/src/app/core/services/config/app-config.service.spec.ts @@ -5,6 +5,7 @@ import { AppVersionMock, StorageServiceMock } from '../../../shared/testing/mock-services' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { AppConfigService } from './app-config.service' @@ -16,7 +17,7 @@ describe('AppConfigService', () => { providers: [ AppConfigService, { provide: AppVersion, useClass: AppVersionMock }, - { provide: StorageService, useClass: StorageServiceMock } + { provide: GlobalStorageService, useClass: StorageServiceMock } ] }) ) diff --git a/src/app/core/services/config/app-config.service.ts b/src/app/core/services/config/app-config.service.ts index 8b8257a42..868b93a44 100644 --- a/src/app/core/services/config/app-config.service.ts +++ b/src/app/core/services/config/app-config.service.ts @@ -9,6 +9,7 @@ import { } from '../../../../assets/data/defaultConfig' import { StorageKeys } from '../../../shared/enums/storage' import { setDateTimeToMidnightEpoch } from '../../../shared/utilities/time' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable() @@ -24,7 +25,10 @@ export class AppConfigService { SETTINGS_WEEKLYREPORT: StorageKeys.SETTINGS_WEEKLYREPORT } - constructor(public storage: StorageService, private appVersion: AppVersion) {} + constructor( + public storage: GlobalStorageService, + private appVersion: AppVersion + ) {} init(enrolmentDate) { return Promise.all([ diff --git a/src/app/core/services/config/config.service.spec.ts b/src/app/core/services/config/config.service.spec.ts index a81601e08..7c92873b8 100644 --- a/src/app/core/services/config/config.service.spec.ts +++ b/src/app/core/services/config/config.service.spec.ts @@ -2,11 +2,13 @@ import { HttpClient, HttpHandler } from '@angular/common/http' import { TestBed } from '@angular/core/testing' import { Platform } from '@ionic/angular' import { PlatformMock } from 'ionic-mocks' +import { HealthkitService } from 'src/app/pages/questions/services/healthkit.service' import { AppConfigServiceMock, AppServerServiceMock, FirebaseAnalyticsServiceMock, + HealthkitServiceMock, KafkaServiceMock, LocalizationServiceMock, LogServiceMock, @@ -58,7 +60,8 @@ describe('ConfigService', () => { { provide: Platform, useClass: PlatformMock }, { provide: RemoteConfigService, useClass: RemoteConfigServiceMock }, { provide: AppServerService, useClass: AppServerServiceMock }, - { provide: MessageHandlerService, useClass: MessageHandlerServiceMock } + { provide: MessageHandlerService, useClass: MessageHandlerServiceMock }, + { provide: HealthkitService, useClass: HealthkitServiceMock } ] }) ) diff --git a/src/app/core/services/config/config.service.ts b/src/app/core/services/config/config.service.ts index f703cd97a..410fcc7a6 100755 --- a/src/app/core/services/config/config.service.ts +++ b/src/app/core/services/config/config.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core' import { compare } from 'compare-versions' +import { HealthkitService } from 'src/app/pages/questions/services/healthkit.service' import { DefaultAppVersion, @@ -46,7 +47,8 @@ export class ConfigService { private analytics: AnalyticsService, private logger: LogService, private remoteConfig: RemoteConfigService, - private messageHandlerService: MessageHandlerService + private messageHandlerService: MessageHandlerService, + private healthKitService: HealthkitService ) { this.notifications.init() } @@ -300,7 +302,8 @@ export class ConfigService { this.questionnaire.reset(), this.schedule.reset(), this.notifications.reset(), - this.localization.init() + this.localization.init(), + this.healthKitService.reset() ]) } diff --git a/src/app/core/services/config/questionnaire.service.spec.ts b/src/app/core/services/config/questionnaire.service.spec.ts index 601839aaa..67921d4cc 100644 --- a/src/app/core/services/config/questionnaire.service.spec.ts +++ b/src/app/core/services/config/questionnaire.service.spec.ts @@ -12,6 +12,7 @@ import { Utility } from '../../../shared/utilities/util' import { GithubClient } from '../misc/github-client.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { QuestionnaireService } from './questionnaire.service' @@ -24,7 +25,7 @@ describe('QuestionnaireService', () => { QuestionnaireService, HttpClient, HttpHandler, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LocalizationService, useClass: LocalizationServiceMock }, { provide: LogService, useClass: LogServiceMock }, { provide: Utility, useClass: UtilityMock }, diff --git a/src/app/core/services/config/questionnaire.service.ts b/src/app/core/services/config/questionnaire.service.ts index 30b2d86c9..bd2b4dbda 100644 --- a/src/app/core/services/config/questionnaire.service.ts +++ b/src/app/core/services/config/questionnaire.service.ts @@ -18,6 +18,7 @@ import { Utility } from '../../../shared/utilities/util' import { GithubClient } from '../misc/github-client.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable() @@ -27,9 +28,10 @@ export class QuestionnaireService { CONIFG_ON_DEMAND_ASSESSMENTS: StorageKeys.CONFIG_ON_DEMAND_ASSESSMENTS, CONFIG_CLINICAL_ASSESSMENTS: StorageKeys.CONFIG_CLINICAL_ASSESSMENTS } + LANG_EN = 'en' constructor( - private storage: StorageService, + private storage: GlobalStorageService, private localization: LocalizationService, private githubClient: GithubClient, private util: Utility, @@ -70,7 +72,10 @@ export class QuestionnaireService { .getContent(uri) .catch(e => { this.logger.error(`Failed to get questionnaires from ${uri}`, e) - uri = this.formatQuestionnaireUri(assessment.questionnaire, '') + uri = this.formatQuestionnaireUri( + assessment.questionnaire, + this.LANG_EN + ) return this.githubClient.getContent(uri) as Promise }) .then(translated => { @@ -90,7 +95,7 @@ export class QuestionnaireService { repo = urlParts[2], branch = urlParts[3], directory = urlParts.slice(4).join('/') - const suffix = lang.length ? `_${lang}` : '' + const suffix = lang.length && lang != this.LANG_EN ? `_${lang}` : '' const fileName = questionnaireName + metadata.type + suffix + metadata.format return ( diff --git a/src/app/core/services/config/remote-config.service.spec.ts b/src/app/core/services/config/remote-config.service.spec.ts index 25373d47c..3c8128a04 100644 --- a/src/app/core/services/config/remote-config.service.spec.ts +++ b/src/app/core/services/config/remote-config.service.spec.ts @@ -9,6 +9,7 @@ import { StorageServiceMock } from '../../../shared/testing/mock-services' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { FirebaseRemoteConfigService, @@ -22,7 +23,7 @@ describe('RemoteConfigService', () => { TestBed.configureTestingModule({ providers: [ RemoteConfigService, - { provide: StorageService, useClass: StorageServiceMock } + { provide: GlobalStorageService, useClass: StorageServiceMock } ] }) ) @@ -44,7 +45,7 @@ describe('FirebaseRemoteConfig', () => { providers: [ FirebaseRemoteConfigService, Platform, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock }, { provide: FirebaseX, useClass: FirebaseMock } ] diff --git a/src/app/core/services/config/remote-config.service.ts b/src/app/core/services/config/remote-config.service.ts index 85a5e19bc..59f054eff 100644 --- a/src/app/core/services/config/remote-config.service.ts +++ b/src/app/core/services/config/remote-config.service.ts @@ -8,13 +8,14 @@ import { ConfigKeys } from '../../../shared/enums/config' import { StorageKeys } from '../../../shared/enums/storage' import { getSeconds } from '../../../shared/utilities/time' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable() export class RemoteConfigService { protected timeoutMillis: number = 10_800_000 - constructor(private storage: StorageService) { + constructor(private storage: GlobalStorageService) { this.storage.get(StorageKeys.REMOTE_CONFIG_CACHE_TIMEOUT).then(timeout => { if (timeout) { this.timeoutMillis = timeout @@ -107,7 +108,7 @@ export class FirebaseRemoteConfigService extends RemoteConfigService { constructor( private firebase: FirebaseX, - storage: StorageService, + storage: GlobalStorageService, private logger: LogService, private platform: Platform ) { diff --git a/src/app/core/services/config/subject-config.service.spec.ts b/src/app/core/services/config/subject-config.service.spec.ts index 99d323ed6..9a1416020 100644 --- a/src/app/core/services/config/subject-config.service.spec.ts +++ b/src/app/core/services/config/subject-config.service.spec.ts @@ -5,6 +5,7 @@ import { StorageServiceMock, TokenServiceMock } from '../../../shared/testing/mock-services' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { TokenService } from '../token/token.service' import { SubjectConfigService } from './subject-config.service' @@ -16,7 +17,7 @@ describe('SubjectConfigService', () => { TestBed.configureTestingModule({ providers: [ SubjectConfigService, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, HttpClient, HttpHandler, { provide: TokenService, useClass: TokenServiceMock } diff --git a/src/app/core/services/config/subject-config.service.ts b/src/app/core/services/config/subject-config.service.ts index 736324c30..b468411b6 100644 --- a/src/app/core/services/config/subject-config.service.ts +++ b/src/app/core/services/config/subject-config.service.ts @@ -10,6 +10,7 @@ import { } from '../../../../assets/data/defaultConfig' import { StorageKeys } from '../../../shared/enums/storage' import { User } from '../../../shared/models/user' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { TokenService } from '../token/token.service' @@ -26,7 +27,7 @@ export class SubjectConfigService { } constructor( - public storage: StorageService, + public storage: GlobalStorageService, private token: TokenService, private http: HttpClient ) {} @@ -102,6 +103,16 @@ export class SubjectConfigService { return this.storage.get(this.SUBJECT_CONFIG_STORE.BASE_URI) } + getKafkaObservationKey() { + return Promise.all([ + this.getSourceID(), + this.getProjectName(), + this.getParticipantLogin() + ]).then(([sourceId, projectId, userId]) => { + return { sourceId, projectId, userId } + }) + } + pullSubjectInformation(): Promise { return Promise.all([ this.token.getAccessHeaders(DefaultRequestEncodedContentType), diff --git a/src/app/core/services/kafka/cache.service.ts b/src/app/core/services/kafka/cache.service.ts new file mode 100755 index 000000000..565d7b8ec --- /dev/null +++ b/src/app/core/services/kafka/cache.service.ts @@ -0,0 +1,170 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http' +import { Injectable } from '@angular/core' + +import { + DefaultClientAcceptType, + DefaultKafkaRequestContentType, + DefaultKafkaURI +} from '../../../../assets/data/defaultConfig' +import { ConfigKeys } from '../../../shared/enums/config' +import { DataEventType } from '../../../shared/enums/events' +import { StorageKeys } from '../../../shared/enums/storage' +import { CacheValue } from '../../../shared/models/cache' +import { + KafkaObject, + KeyExport, + SchemaType +} from '../../../shared/models/kafka' +import { RemoteConfigService } from '../config/remote-config.service' +import { SubjectConfigService } from '../config/subject-config.service' +import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' +import { HealthStorageService } from '../storage/health-storage.service' +import { StorageService } from '../storage/storage.service' +import { TokenService } from '../token/token.service' +import { AnalyticsService } from '../usage/analytics.service' +import { SchemaService } from './schema.service' + +@Injectable() +export class CacheService { + URI_topics: string = '/topics/' + HEALTH_CACHE_LIMIT = 10000 + + private readonly KAFKA_STORE = { + LAST_UPLOAD_DATE: StorageKeys.LAST_UPLOAD_DATE, + CACHE_ANSWERS: StorageKeys.CACHE_ANSWERS + } + + private isCacheSending: boolean + + constructor( + private storage: GlobalStorageService, + private healthStore: HealthStorageService, + private analytics: AnalyticsService, + private logger: LogService + ) {} + + init() { + return Promise.all([this.setCache({}), this.setHealthCache({})]) + } + + storeInCache(type, kafkaObject: KafkaObject, cacheValue: any) { + if (type == SchemaType.HEALTHKIT) { + return this.getHealthCache().then(cache => { + const data = Object.entries(cacheValue.kafkaObject.value) + cache = cache ? cache : {} + data.map(([key, values]: [any, any[]]) => { + values.map(v => { + const cacheKey = v['time'] + Math.random() + cache[cacheKey] = { + avsc: 'questionnaire', + name: 'healthkit_' + key, + kafkaObject: { value: v } + } + }) + }) + return this.setHealthCache(cache) + }) + } else { + return this.getCache().then(cache => { + this.logger.log('KAFKA-SERVICE: Caching answers.') + cache[kafkaObject.value.time] = cacheValue + this.sendDataEvent(DataEventType.CACHED, cacheValue) + return this.setCache(cache) + }) + } + } + + removeFromCache(cacheKeys: number[]) { + if (!cacheKeys.length) return Promise.resolve() + return this.getCache().then(cache => { + if (cache) { + cacheKeys.map(cacheKey => { + if (cache[cacheKey]) { + this.sendDataEvent( + DataEventType.REMOVED_FROM_CACHE, + cache[cacheKey] + ) + this.logger.log('Deleting ' + cacheKey) + delete cache[cacheKey] + } + }) + this.setLastUploadDate(Date.now()) + return this.setCache(cache) + } + }) + } + + removeFromHealthCache(cacheKeys: number[]) { + if (!cacheKeys.length) return Promise.resolve() + return this.healthStore + .remove(cacheKeys) + .then(() => this.setLastUploadDate(Date.now())) + } + + setHealthCache(cache) { + return this.healthStore.set(StorageKeys.CACHE_ANSWERS, cache) + } + + getHealthCache() { + return this.healthStore.get(this.KAFKA_STORE.CACHE_ANSWERS).then(data => { + return Object.keys(data) + .slice(0, this.HEALTH_CACHE_LIMIT) + .reduce((result, key) => { + result[key] = data[key] + return result + }, {}) + }) + } + + setCache(cache) { + return this.storage.set(this.KAFKA_STORE.CACHE_ANSWERS, cache) + } + + setCacheSending(val: boolean) { + this.isCacheSending = val + } + + setLastUploadDate(date) { + return this.storage.set(this.KAFKA_STORE.LAST_UPLOAD_DATE, date) + } + + getCache() { + return this.storage.get(this.KAFKA_STORE.CACHE_ANSWERS) + } + + getLastUploadDate() { + return this.storage.get(this.KAFKA_STORE.LAST_UPLOAD_DATE) + } + + getHealthCacheSize() { + return this.healthStore + .get(this.KAFKA_STORE.CACHE_ANSWERS) + .then(cache => Object.keys(cache).reduce((s, k) => (k ? s + 1 : s), 0)) + } + + getCacheSize() { + return this.storage + .get(this.KAFKA_STORE.CACHE_ANSWERS) + .then(cache => Object.keys(cache).reduce((s, k) => (k ? s + 1 : s), 0)) + } + + sendDataEvent(type, cacheValue: CacheValue, error?) { + const value = cacheValue.kafkaObject.value + this.analytics.logEvent(type, { + name: cacheValue.repository ? SchemaType.ASSESSMENT : cacheValue.name, + timestamp: String(value.time), + questionnaire_name: value.name, + questionnaire_timestamp: String(value.timeNotification), + error: JSON.stringify(error) + }) + } + + reset() { + return Promise.all([ + this.setCache({}), + this.healthStore.clear(), + this.setLastUploadDate(null) + ]) + } +} diff --git a/src/app/core/services/kafka/converters/app-event-converter.service.ts b/src/app/core/services/kafka/converters/app-event-converter.service.ts new file mode 100644 index 000000000..2dbda6a90 --- /dev/null +++ b/src/app/core/services/kafka/converters/app-event-converter.service.ts @@ -0,0 +1,39 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { EventValueExport } from 'src/app/shared/models/event' +import { QuestionType } from 'src/app/shared/models/question' +import { getSeconds } from 'src/app/shared/utilities/time' +import { Utility } from 'src/app/shared/utilities/util' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class AppEventConverterService extends ConverterService { + constructor( + logger: LogService, + http: HttpClient, + token: TokenService, + private utility: Utility + ) { + super(logger, http, token) + } + + init() {} + + getKafkaTopic(payload): Promise { + return Promise.resolve('questionnaire_app_event') + } + + processData(payload) { + const Event: EventValueExport = { + time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), + eventType: payload.eventType.toUpperCase(), + questionnaireName: payload.questionnaireName, + metadata: this.utility.mapToObject(payload.metadata) + } + return Event + } +} diff --git a/src/app/core/services/kafka/converters/assessment-converter.service.ts b/src/app/core/services/kafka/converters/assessment-converter.service.ts new file mode 100644 index 000000000..6f79e0995 --- /dev/null +++ b/src/app/core/services/kafka/converters/assessment-converter.service.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { QuestionType } from 'src/app/shared/models/question' +import { getSeconds } from 'src/app/shared/utilities/time' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class AssessmentConverterService extends ConverterService { + GENERAL_TOPIC: string = 'questionnaire_response' + + constructor(logger: LogService, http: HttpClient, token: TokenService) { + super(logger, http, token) + } + + init() {} + + processData(payload) { + const task = payload.task + if (!task) return {} + const data = payload.data + const processedAnswers = this.processAnswers(data.answers, data.timestamps) + const Answer: AnswerValueExport = { + name: task.name, + version: 'version', + answers: processedAnswers, + time: data.time, + timeCompleted: data.timeCompleted, + timeNotification: getSeconds({ milliseconds: task.timestamp }) + } + return Answer + } + + processAnswers(answers, timestamps) { + this.logger.log('Answers to process', answers) + const values = Object.entries(answers).map(([key, value]) => ({ + questionId: key.toString(), + value: value.toString(), + startTime: timestamps[key].startTime, + endTime: timestamps[key].endTime + })) + return values + } + + getKafkaTopic(payload, topics): Promise { + const name = payload.name + return this.getKafkaTopicFromSpecifications(name).then(specTopic => { + if (this.topicExists(specTopic, topics)) { + return Promise.resolve(specTopic) + } + const questionnaireTopic = `${payload.avsc}_${payload.name}` + if (this.topicExists(questionnaireTopic, topics)) { + return Promise.resolve(questionnaireTopic) + } + const defaultTopic = this.GENERAL_TOPIC + if (this.topicExists(defaultTopic, topics)) { + return Promise.resolve(defaultTopic) + } + return Promise.resolve('questionnaire_response') + }) + } +} diff --git a/src/app/core/services/kafka/converters/completion-log-converter.service.ts b/src/app/core/services/kafka/converters/completion-log-converter.service.ts new file mode 100644 index 000000000..aba68a345 --- /dev/null +++ b/src/app/core/services/kafka/converters/completion-log-converter.service.ts @@ -0,0 +1,35 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { CompletionLogValueExport } from 'src/app/shared/models/completion-log' +import { QuestionType } from 'src/app/shared/models/question' +import { getSeconds } from 'src/app/shared/utilities/time' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class CompletionLogConverterService extends ConverterService { + constructor(logger: LogService, http: HttpClient, token: TokenService) { + super(logger, http, token) + } + + init() {} + + getKafkaTopic(payload): Promise { + return Promise.resolve('questionnaire_completion_log') + } + + processData(payload) { + const CompletionLog: CompletionLogValueExport = { + name: payload.name, + time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), + timeNotification: getSeconds({ + milliseconds: payload.timeNotification + }), + completionPercentage: payload.percentage + } + return CompletionLog + } +} diff --git a/src/app/core/services/kafka/converters/converter-factory.service..ts b/src/app/core/services/kafka/converters/converter-factory.service..ts new file mode 100644 index 000000000..938594b08 --- /dev/null +++ b/src/app/core/services/kafka/converters/converter-factory.service..ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { SchemaType } from 'src/app/shared/models/kafka' + +import { AppEventConverterService } from './app-event-converter.service' +import { AssessmentConverterService } from './assessment-converter.service' +import { CompletionLogConverterService } from './completion-log-converter.service' +import { HealthkitConverterService } from './healthkit-converter.service' +import { KeyConverterService } from './key-converter.service' +import { TimezoneConverterService } from './timezone-converter.service' + +@Injectable() +export class ConverterFactoryService { + constructor( + private assessmentConverter: AssessmentConverterService, + private healthkitConverter: HealthkitConverterService, + private appEventConverter: AppEventConverterService, + private completionLogConverter: CompletionLogConverterService, + private timzoneConverter: TimezoneConverterService, + private keyConverter: KeyConverterService + ) {} + + init() {} + + getConverter(type) { + switch (this.classify(type)) { + case SchemaType.HEALTHKIT: + return this.healthkitConverter + case SchemaType.ASSESSMENT: + return this.assessmentConverter + case SchemaType.COMPLETION_LOG: + return this.completionLogConverter + case SchemaType.TIMEZONE: + return this.timzoneConverter + case SchemaType.APP_EVENT: + return this.appEventConverter + case SchemaType.KEY: + return this.keyConverter + default: + return this.assessmentConverter + } + } + + classify(type) { + if (type.includes(SchemaType.HEALTHKIT)) return SchemaType.HEALTHKIT + else return type + } +} diff --git a/src/app/core/services/kafka/converters/converter.service.ts b/src/app/core/services/kafka/converters/converter.service.ts new file mode 100644 index 000000000..e87d1688c --- /dev/null +++ b/src/app/core/services/kafka/converters/converter.service.ts @@ -0,0 +1,129 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import * as AvroSchema from 'avro-js' +import { SchemaAndValue, SchemaMetadata } from 'src/app/shared/models/kafka' +import { + DefaultEndPoint, + DefaultSchemaSpecEndpoint +} from 'src/assets/data/defaultConfig' +import * as YAML from 'yaml' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' + +@Injectable() +export abstract class ConverterService { + schemas = {} + specifications + URI_schema: string = '/schema/subjects/' + URI_version: string = '/versions/' + BASE_URI: string + + constructor( + public logger: LogService, + private http: HttpClient, + public token: TokenService + ) { + this.updateURI() + this.getRadarSpecifications() + } + + init() {} + + abstract getKafkaTopic(payload, topics?) + + processData(data) {} + + getSchemas(topic) { + if (this.schemas[topic]) return this.schemas[topic] + else { + const versionStr = this.URI_version + 'latest' + const uri = + this.BASE_URI + this.URI_schema + topic + '-value' + versionStr + const schema = this.getLatestKafkaSchemaVersion(uri) + this.schemas[topic] = schema + return schema + } + } + + convertToRecord(kafkaValue, topic, valueSchemaMetadata) { + const value = JSON.parse(valueSchemaMetadata.schema) + const record = { + schema: valueSchemaMetadata.id, + value: this.convertToAvro(value, kafkaValue), + topic + } + return record + } + + batchConvertToRecord(kafkaValues, topic, valueSchemaMetadata) { + const value = JSON.parse(valueSchemaMetadata.schema) + return this.batchConvertToAvro(value, kafkaValues).map(v => ({ + value: v, + schema: valueSchemaMetadata.id + })) + } + + convertToAvro(schema, value): any { + return AvroSchema.parse(schema).clone(value, { wrapUnions: true }) + } + + batchConvertToAvro(schema, values): any { + const parsedSchema = AvroSchema.parse(schema) + return values.map(v => parsedSchema.clone(v, { wrapUnions: true })) + } + + getUniqueTimeNow() { + return new Date().getTime() + Math.random() + } + + getLatestKafkaSchemaVersion(uri): Promise { + return this.http + .get(uri) + .toPromise() + .catch(e => { + throw this.logger.error('Failed to get latest Kafka schema versions', e) + }) + } + + getRadarSpecifications(): Promise { + return this.http + .get(DefaultSchemaSpecEndpoint) + .toPromise() + .then( + res => (this.specifications = YAML.parse(atob(res['content'])).data) + ) + .then(specs => (this.specifications = specs)) + .catch(e => { + this.logger.error('Failed to get valid RADAR Schema specifications', e) + return null + }) + } + + getKafkaTopicFromSpecifications(name): Promise { + const type = name.toLowerCase() + if (this.specifications) { + const spec = this.specifications.find(t => t.type.toLowerCase() == type) + if (spec && spec.topic) { + return Promise.resolve(spec.topic) + } + } + return Promise.resolve('questionnaire_response') + } + + updateURI() { + return this.token.getURI().then(uri => (this.BASE_URI = uri)) + } + + topicExists(topic: string, topics: string[] | null) { + if (!topics || topics.includes(topic)) { + return true + } else { + this.logger.error( + `Cannot send data to specification topic ${topic} because target server does not have it`, + null + ) + return false + } + } +} diff --git a/src/app/core/services/kafka/converters/healthkit-converter.service.ts b/src/app/core/services/kafka/converters/healthkit-converter.service.ts new file mode 100644 index 000000000..c56658c68 --- /dev/null +++ b/src/app/core/services/kafka/converters/healthkit-converter.service.ts @@ -0,0 +1,88 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { + HealthKitDataTypeKey, + HealthkitDataType, + HealthkitStringDataType +} from 'src/app/shared/models/health' +import { QuestionType } from 'src/app/shared/models/question' +import { getSeconds } from 'src/app/shared/utilities/time' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class HealthkitConverterService extends ConverterService { + GENERAL_TOPIC: string = 'questionnaire_response' + + HEALTHKIT_KEYS: Set = new Set([ + HealthkitDataType.ACTIVITY, + HealthkitDataType.APPLE_EXERCISE_TIME, + HealthkitDataType.CALORIES, + HealthkitDataType.DISTANCE, + HealthkitDataType.STAIRS, + HealthkitDataType.VO2MAX + ]) + + constructor(logger: LogService, http: HttpClient, token: TokenService) { + super(logger, http, token) + } + + init() {} + + processData(payload) { + const answers = payload.data.answers + let processedData = {} + Object.entries(answers).forEach(([k, v]) => { + if (v && v instanceof Array) { + processedData[k] = this.processSingleDatatype( + k, + v, + payload.data.timeCompleted + ) + } + }) + return processedData + } + + processSingleDatatype(key, data, timeReceived) { + const type = this.getDataTypeFromKey(key) + const results = data.map(d => + Object.assign( + {}, + { + time: getSeconds({ milliseconds: new Date(d.startDate).getTime() }), + endTime: getSeconds({ milliseconds: new Date(d.endDate).getTime() }), + timeReceived: timeReceived, + sourceId: d.sourceBundleId, + sourceName: d.sourceName, + unit: d.unit, + key, + intValue: null, + floatValue: null, + doubleValue: null, + stringValue: null + }, + { [type]: d.value } + ) + ) + return results + } + + getDataTypeFromKey(key) { + if ( + Object.values(HealthkitStringDataType).includes( + key as HealthkitStringDataType + ) + ) { + return HealthKitDataTypeKey.STRING + } else return HealthKitDataTypeKey.FLOAT + } + + getKafkaTopic(payload, topics): Promise { + const key = payload.key + return Promise.resolve('active_apple_healthkit_' + key) + } +} diff --git a/src/app/core/services/kafka/converters/key-converter.service.ts b/src/app/core/services/kafka/converters/key-converter.service.ts new file mode 100644 index 000000000..9873c1c1e --- /dev/null +++ b/src/app/core/services/kafka/converters/key-converter.service.ts @@ -0,0 +1,63 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { KeyExport } from 'src/app/shared/models/kafka' +import { QuestionType } from 'src/app/shared/models/question' +import { getSeconds } from 'src/app/shared/utilities/time' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class KeyConverterService extends ConverterService { + constructor(logger: LogService, http: HttpClient, token: TokenService) { + super(logger, http, token) + } + + init() {} + + getKafkaTopic(payload): Promise { + return Promise.resolve() + } + + getSchemas(topic) { + if (this.schemas[topic]) return this.schemas[topic] + else { + const versionStr = this.URI_version + 'latest' + const uri = + this.BASE_URI + + this.URI_schema + + 'questionnaire_response' + + '-key' + + versionStr + const schema = this.getLatestKafkaSchemaVersion(uri) + this.schemas[topic] = schema + return schema + } + } + + convertToRecord(kafkaKey, topics, schema): any { + return this.getKafkaTopic(kafkaKey).then(topic => + this.getSchemas(topic).then(keySchemaMetadata => { + const key = JSON.parse(keySchemaMetadata.schema) + const record = { + schema: keySchemaMetadata.id, + value: this.convertToAvro(key, kafkaKey), + topic + } + return record + }) + ) + } + + processData(payload) { + const key: KeyExport = { + sourceId: payload.sourceId, + userId: payload.userId, + projectId: payload.projectId + } + + return key + } +} diff --git a/src/app/core/services/kafka/converters/timezone-converter.service.ts b/src/app/core/services/kafka/converters/timezone-converter.service.ts new file mode 100644 index 000000000..757d423a5 --- /dev/null +++ b/src/app/core/services/kafka/converters/timezone-converter.service.ts @@ -0,0 +1,31 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { AnswerValueExport } from 'src/app/shared/models/answer' +import { QuestionType } from 'src/app/shared/models/question' +import { ApplicationTimeZoneValueExport } from 'src/app/shared/models/timezone' +import { getSeconds } from 'src/app/shared/utilities/time' + +import { LogService } from '../../misc/log.service' +import { TokenService } from '../../token/token.service' +import { ConverterService } from './converter.service' + +@Injectable() +export class TimezoneConverterService extends ConverterService { + constructor(logger: LogService, http: HttpClient, token: TokenService) { + super(logger, http, token) + } + + init() {} + + getKafkaTopic(payload): Promise { + return Promise.resolve('questionnaire_timezone') + } + + processData(payload) { + const ApplicationTimeZone: ApplicationTimeZoneValueExport = { + time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), + offset: getSeconds({ minutes: new Date().getTimezoneOffset() }) + } + return ApplicationTimeZone + } +} diff --git a/src/app/core/services/kafka/kafka.service.spec.ts b/src/app/core/services/kafka/kafka.service.spec.ts index 92a5eb80d..a70376909 100644 --- a/src/app/core/services/kafka/kafka.service.spec.ts +++ b/src/app/core/services/kafka/kafka.service.spec.ts @@ -2,19 +2,22 @@ import { HttpClient, HttpHandler } from '@angular/common/http' import { TestBed } from '@angular/core/testing' import { + CacheServiceMock, FirebaseAnalyticsServiceMock, - LogServiceMock, RemoteConfigServiceMock, + LogServiceMock, + RemoteConfigServiceMock, SchemaServiceMock, StorageServiceMock, TokenServiceMock } from '../../../shared/testing/mock-services' +import { RemoteConfigService } from '../config/remote-config.service' import { LogService } from '../misc/log.service' -import { StorageService } from '../storage/storage.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { TokenService } from '../token/token.service' import { AnalyticsService } from '../usage/analytics.service' +import { CacheService } from './cache.service' import { KafkaService } from './kafka.service' import { SchemaService } from './schema.service' -import { RemoteConfigService } from "../config/remote-config.service"; describe('KafkaService', () => { let service @@ -25,15 +28,16 @@ describe('KafkaService', () => { KafkaService, HttpClient, HttpHandler, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock }, { provide: TokenService, useClass: TokenServiceMock }, + { provide: CacheService, useClass: CacheServiceMock }, { provide: SchemaService, useClass: SchemaServiceMock }, { provide: AnalyticsService, useClass: FirebaseAnalyticsServiceMock }, - { provide: RemoteConfigService, useClass: RemoteConfigServiceMock }, + { provide: RemoteConfigService, useClass: RemoteConfigServiceMock } ] }) ) diff --git a/src/app/core/services/kafka/kafka.service.ts b/src/app/core/services/kafka/kafka.service.ts index 274ca76c9..ce54a0f3f 100755 --- a/src/app/core/services/kafka/kafka.service.ts +++ b/src/app/core/services/kafka/kafka.service.ts @@ -1,10 +1,13 @@ import { HttpClient, HttpHeaders } from '@angular/common/http' import { Injectable } from '@angular/core' +import * as pako from 'pako' import { DefaultClientAcceptType, + DefaultCompressedContentEncoding, DefaultKafkaRequestContentType, - DefaultKafkaURI + DefaultKafkaURI, + DefaultRequestJSONContentType } from '../../../../assets/data/defaultConfig' import { ConfigKeys } from '../../../shared/enums/config' import { DataEventType } from '../../../shared/enums/events' @@ -13,9 +16,11 @@ import { CacheValue, KeyValue } from '../../../shared/models/cache' import { KafkaObject, SchemaType } from '../../../shared/models/kafka' import { RemoteConfigService } from '../config/remote-config.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { TokenService } from '../token/token.service' import { AnalyticsService } from '../usage/analytics.service' +import { CacheService } from './cache.service' import { SchemaService } from './schema.service' @Injectable() @@ -23,20 +28,18 @@ export class KafkaService { private static DEFAULT_TOPIC_CACHE_VALIDITY = 600_000 // 10 minutes URI_topics: string = '/topics/' + DEFAULT_KAFKA_AVSC = 'questionnaire' - private readonly KAFKA_STORE = { - LAST_UPLOAD_DATE: StorageKeys.LAST_UPLOAD_DATE, - CACHE_ANSWERS: StorageKeys.CACHE_ANSWERS - } private KAFKA_CLIENT_URL: string - private BASE_URI: string private isCacheSending: boolean private topics: string[] = null private lastTopicFetch: number = 0 private TOPIC_CACHE_VALIDITY = KafkaService.DEFAULT_TOPIC_CACHE_VALIDITY + HTTP_ERROR = 'HttpErrorResponse' constructor( - private storage: StorageService, + private storage: GlobalStorageService, + private cache: CacheService, private token: TokenService, private schema: SchemaService, private analytics: AnalyticsService, @@ -50,17 +53,16 @@ export class KafkaService { init() { return Promise.all([ - this.setCache({}), + this.cache.setCache({}), this.updateTopicCacheValidity(), this.fetchTopics() ]) } updateURI() { - return this.token.getURI().then(uri => { - this.BASE_URI = uri - this.KAFKA_CLIENT_URL = uri + DefaultKafkaURI - }) + return this.token + .getURI() + .then(uri => (this.KAFKA_CLIENT_URL = uri + DefaultKafkaURI)) } readTopicCacheValidity() { @@ -93,14 +95,12 @@ export class KafkaService { } private fetchTopics() { - return this.getAccessToken() - .then(accessToken => + return this.getKafkaHeaders(DefaultRequestJSONContentType) + .then(headers => this.http .get(this.KAFKA_CLIENT_URL + this.URI_topics, { - observe: 'body', - headers: new HttpHeaders() - .set('Authorization', 'Bearer ' + accessToken) - .set('Accept', DefaultClientAcceptType) + headers, + observe: 'body' }) .toPromise() ) @@ -126,147 +126,141 @@ export class KafkaService { } } - prepareKafkaObjectAndSend(type, payload, keepInCache?): Promise { + prepareKafkaObjectAndStore(type, payload) { + const name = type == SchemaType.ASSESSMENT ? payload.metadata.name : type const value = this.schema.getKafkaObjectValue(type, payload) - const keyPromise = this.schema.getKafkaObjectKey() - const metaDataPromise = this.schema.getMetaData(type, payload) - return Promise.all([keyPromise, metaDataPromise]).then( - ([key, metaData]) => { - const kafkaObject: KafkaObject = { key: key, value: value } - const cacheValue: CacheValue = Object.assign({}, metaData, { - kafkaObject - }) - return cacheValue - } + const kafkaObject: KafkaObject = { value } + const cacheValue: CacheValue = { + kafkaObject, + name, + avsc: payload.metadata ? payload.metadata.avsc : this.DEFAULT_KAFKA_AVSC + } + this.sendDataEvent( + DataEventType.PREPARED_OBJECT, + name, + kafkaObject.value.name, + kafkaObject.value.timestamp ) + return this.cache.storeInCache(type, kafkaObject, cacheValue) } - storeInCache(cacheValue: CacheValue) { - return this.getCache().then(cache => { - console.log('KAFKA-SERVICE: Caching answers.') - const kafkaObjectVal = cacheValue.kafkaObject.value - const cacheKey = kafkaObjectVal.time - ? kafkaObjectVal.time - : kafkaObjectVal.startTime - cache[cacheKey] = cacheValue - this.sendDataEvent(DataEventType.CACHED, cacheValue) - return this.setCache(cache) - }) - } - - storeHealthDataInCache(cacheValue: CacheValue[]) { - return this.getHealthCache().then(cache => { - cache = cache ? cache : {} - cacheValue.map((c: CacheValue) => { - console.log('KAFKA-SERVICE: Caching answers.') - const kafkaObjectVal = c.kafkaObject.value - const cacheKey = kafkaObjectVal.startTime - ? kafkaObjectVal.startTime + Math.random() - : kafkaObjectVal.time - cache[cacheKey] = c - }) - return this.setHealthCache(cache) - }) - } - - sendAllFromCache() { + sendAllFromCache(): Promise { + let successKeys = [] + let failedKeys = [] if (this.isCacheSending) return Promise.resolve([]) - this.setCacheSending(true) + this.cache.setCacheSending(true) return Promise.all([ - this.getCache(), - this.getHealthCache(), - this.getKafkaHeaders(), - this.schema.getRadarSpecifications(), - this.getTopics() + this.cache.getCache(), + this.cache.getHealthCache(), + this.getKafkaHeaders(DefaultKafkaRequestContentType) ]) - .then(([cache, healthCache, headers, specifications, topics]) => { - const cacheByTopics = {} - const completeCache = Object.entries( - Object.assign({}, cache, healthCache) - ).filter(([k, v]) => k && v) - const sendPromises = completeCache.map(([k, v]: any) => - this.schema - .getKafkaTopic(specifications, v.name, v.avsc, topics) - .then(topic => { - if (!cacheByTopics[topic]) cacheByTopics[topic] = [] - return cacheByTopics[topic].push({ key: k, value: v }) - }) - ) - return Promise.all(sendPromises).then(res => { - const keys = Object.keys(cacheByTopics) + .then(([cache, healthCache, headers]) => { + const completeCache = { ...cache, ...healthCache } + return this.convertCacheToRecords(completeCache).then(records => { return Promise.all( - keys.map(k => - this.sendToKafka(k, cacheByTopics[k], headers).catch(e => e) + records.map(r => + this.sendToKafka(r.topic, r.record, headers) + .then(() => (successKeys = successKeys.concat(r.cacheKey))) + .catch(e => { + failedKeys = failedKeys.concat(r.cacheKey) + return this.logger.error( + 'Failed to send data from cache to kafka', + e + ) + }) ) ) }) }) - .then((keys: any[][]) => { - const allKeys = [].concat.apply([], keys) - const successKeys = allKeys.filter(k => !(k instanceof Error)) - return this.removeFromCache(successKeys) - .then(() => this.removeFromHealthCache(successKeys)) - .then(() => this.setCacheSending(false)) - .then(() => allKeys) - }) + .then(() => + this.cache + .removeFromCache(successKeys) + .then(() => this.cache.removeFromHealthCache(successKeys)) + .then(() => { + this.cache.setCacheSending(false) + return { successKeys, failedKeys } + }) + ) .catch(e => { - this.setCacheSending(false) + this.cache.setCacheSending(false) return [this.logger.error('Failed to send all data from cache', e)] }) } - sendToKafka(topic: string, values: KeyValue[], headers): Promise { - const kafkaObjects = values.map(v => v.value.kafkaObject) - return this.schema - .getKafkaPayload(kafkaObjects, topic, this.BASE_URI) - .then(data => - this.http - .post(this.KAFKA_CLIENT_URL + this.URI_topics + topic, data, { - headers - }) - .toPromise() - ) - .then(() => - values.map(v => this.sendDataEvent(DataEventType.SEND_SUCCESS, v.value)) - ) - .then(() => values.map(v => v.key)) - .catch(error => { - values.map(v => - this.sendDataEvent( - DataEventType.SEND_ERROR, - v.value, - JSON.stringify(error) + convertCacheToRecords(cache) { + return this.schema.getKafkaObjectKey().then(key => { + const groupedCache = {} + Object.entries(cache).map(([k, v]: [any, CacheValue]) => { + if (!v || !v.kafkaObject) return + const type = v.name + if (!groupedCache[type]) groupedCache[type] = [] + groupedCache[type].push({ + key, + value: { key: k, value: v.kafkaObject.value } + }) + }) + let allRecords = [] + Object.entries(groupedCache).map(([k, v]: [any, any]) => { + const type = k + return allRecords.push( + this.schema.getKafkaPayload( + type, + v[0].key, + v.map(a => a.value.value), + v.map(a => a.value.key), + this.topics ) ) - throw error }) + return Promise.all(allRecords) + }) } - removeFromCache(cacheKeys: number[]) { - if (!cacheKeys.length) return Promise.resolve() - return this.getCache().then(cache => { - if (cache) { - cacheKeys.map(cacheKey => { - if (cache[cacheKey]) { - this.sendDataEvent( - DataEventType.REMOVED_FROM_CACHE, - cache[cacheKey] - ) - console.log('Deleting ' + cacheKey) - delete cache[cacheKey] - } - }) - this.setLastUploadDate(Date.now()) - return this.setCache(cache) - } - }) + sendToKafka(topic, record, headers): Promise { + const allRecords = record.records + const compressed = pako.gzip(JSON.stringify(record)).buffer + return this.postData( + compressed, + topic, + headers.set('Content-Encoding', DefaultCompressedContentEncoding) + ) + .catch(e => { + if (e.name == this.HTTP_ERROR) { + this.logger.log('Retrying uncompressed..') + return this.postData(record, topic, headers) + } + throw e + }) + .then(() => this.sendEvent(allRecords[0], DataEventType.SEND_SUCCESS)) + .catch(e => { + this.sendEvent(allRecords[0], DataEventType.SEND_ERROR, e) + throw e + }) + } + + sendEvent(record, eventType, error?) { + this.sendDataEvent( + DataEventType.SEND_SUCCESS, + eventType, + record.name ? record.value.name : record.value.questionnaireName, + record.time, + error ? JSON.stringify(error) : '' + ) + } + + postData(data, topic, headers) { + return this.http + .post(this.KAFKA_CLIENT_URL + this.URI_topics + topic, data, { + headers + }) + .toPromise() } removeFromHealthCache(cacheKeys: number[]) { - if (!cacheKeys.length) return Promise.resolve() - return this.storage - .removeHealthData(cacheKeys) - .then(() => this.setLastUploadDate(Date.now())) + // if (!cacheKeys.length) return Promise.resolve() + // return this.storage + // .removeHealthData(cacheKeys) + // .then(() => this.setLastUploadDate(Date.now())) } getAccessToken() { @@ -275,12 +269,12 @@ export class KafkaService { .then(tokens => tokens.access_token) } - getKafkaHeaders() { + getKafkaHeaders(contentType) { return this.getAccessToken() .then(accessToken => new HttpHeaders() .set('Authorization', 'Bearer ' + accessToken) - .set('Content-Type', DefaultKafkaRequestContentType) + .set('Content-Type', contentType) .set('Accept', DefaultClientAcceptType) ) .catch(e => { @@ -289,15 +283,7 @@ export class KafkaService { } setCache(cache) { - return this.storage.set(this.KAFKA_STORE.CACHE_ANSWERS, cache) - } - - setHealthCache(cache) { - return this.storage.setHealthData(cache) - } - - resetHealthCache() { - return this.storage.resetHealthData() + return this.storage.set(StorageKeys.CACHE_ANSWERS, cache) } setCacheSending(val: boolean) { @@ -305,54 +291,35 @@ export class KafkaService { } setLastUploadDate(date) { - return this.storage.set(this.KAFKA_STORE.LAST_UPLOAD_DATE, date) + return this.storage.set(StorageKeys.LAST_UPLOAD_DATE, date) } setHealthkitPollTimes(dic) { return this.storage.set(StorageKeys.HEALTH_LAST_POLL_TIMES, dic) } - getCache() { - return this.storage.get(this.KAFKA_STORE.CACHE_ANSWERS) - } - - getHealthCache() { - return this.storage.getHealthData(this.KAFKA_STORE.CACHE_ANSWERS) - } - getLastUploadDate() { - return this.storage.get(this.KAFKA_STORE.LAST_UPLOAD_DATE) + return this.cache.getLastUploadDate() } getHealthCacheSize() { - return this.getHealthCache().then(cache => - Object.keys(cache).reduce((s, k) => (k ? s + 1 : s), 0) - ) + return this.cache.getHealthCacheSize() } getCacheSize() { - return this.storage - .get(this.KAFKA_STORE.CACHE_ANSWERS) - .then(cache => Object.keys(cache).reduce((s, k) => (k ? s + 1 : s), 0)) + return this.cache.getCacheSize() } - sendDataEvent(type, cacheValue: CacheValue, error?) { - const value = cacheValue.kafkaObject.value + sendDataEvent(type, name, questionnaire, timestamp, error?) { this.analytics.logEvent(type, { - name: cacheValue.repository ? SchemaType.ASSESSMENT : cacheValue.name, - timestamp: String(value.time), - questionnaire_name: value.name, - questionnaire_timestamp: String(value.timeNotification), + name, + questionnaire_name: questionnaire, + questionnaire_timestamp: String(timestamp), error: JSON.stringify(error) }) } reset() { - return Promise.all([ - this.setCache({}), - this.resetHealthCache(), - this.setLastUploadDate(null), - this.setHealthkitPollTimes({}) - ]) + return this.cache.reset() } } diff --git a/src/app/core/services/kafka/schema.service.spec.ts b/src/app/core/services/kafka/schema.service.spec.ts index e04b278d5..c3a1af44a 100644 --- a/src/app/core/services/kafka/schema.service.spec.ts +++ b/src/app/core/services/kafka/schema.service.spec.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpHandler } from '@angular/common/http' import { TestBed } from '@angular/core/testing' import { + ConverterFactoryServiceMock, LocalizationServiceMock, LogServiceMock, QuestionnaireServiceMock, @@ -16,7 +17,8 @@ import { RemoteConfigService } from '../config/remote-config.service' import { SubjectConfigService } from '../config/subject-config.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' -import { StorageService } from '../storage/storage.service' +import { GlobalStorageService } from '../storage/global-storage.service' +import { ConverterFactoryService } from './converters/converter-factory.service.' import { SchemaService } from './schema.service' describe('SchemaService', () => { @@ -27,11 +29,15 @@ describe('SchemaService', () => { providers: [ SchemaService, { provide: QuestionnaireService, useClass: QuestionnaireServiceMock }, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock }, { provide: LocalizationService, useClass: LocalizationServiceMock }, { provide: SubjectConfigService, useClass: SubjectConfigServiceMock }, { provide: RemoteConfigService, useClass: RemoteConfigServiceMock }, + { + provide: ConverterFactoryService, + useClass: ConverterFactoryServiceMock + }, { provide: Utility, useClass: UtilityMock }, HttpClient, HttpHandler diff --git a/src/app/core/services/kafka/schema.service.ts b/src/app/core/services/kafka/schema.service.ts index 798ce6cb5..7593e6ec7 100644 --- a/src/app/core/services/kafka/schema.service.ts +++ b/src/app/core/services/kafka/schema.service.ts @@ -1,231 +1,62 @@ -import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' -import * as AvroSchema from 'avro-js' -import { HealthkitSchemaType } from 'src/app/shared/models/health' -import * as YAML from 'yaml' -import { - DefaultHealthkitTopicPrefix, - DefaultSchemaSpecEndpoint -} from '../../../../assets/data/defaultConfig' -import { ConfigKeys } from '../../../shared/enums/config' -import { AnswerValueExport } from '../../../shared/models/answer' -import { QuestionnaireMetadata } from '../../../shared/models/assessment' -import { CompletionLogValueExport } from '../../../shared/models/completion-log' -import { EventValueExport } from '../../../shared/models/event' import { KeyExport, SchemaMetadata, SchemaType } from '../../../shared/models/kafka' -import { Task } from '../../../shared/models/task' -import { ApplicationTimeZoneValueExport } from '../../../shared/models/timezone' -import { getSeconds } from '../../../shared/utilities/time' -import { Utility } from '../../../shared/utilities/util' -import { QuestionnaireService } from '../config/questionnaire.service' -import { RemoteConfigService } from '../config/remote-config.service' import { SubjectConfigService } from '../config/subject-config.service' -import { LogService } from '../misc/log.service' +import { ConverterFactoryService } from './converters/converter-factory.service.' @Injectable() export class SchemaService { - URI_schema: string = '/schema/subjects/' - URI_version: string = '/versions/' - GENERAL_TOPIC: string = 'questionnaire_response' - private schemas: { - [key: string]: [Promise, Promise] - } = {} - constructor( - public questionnaire: QuestionnaireService, - private config: SubjectConfigService, - private http: HttpClient, - private logger: LogService, - private remoteConfig: RemoteConfigService, - private utility: Utility + private converterFactory: ConverterFactoryService, + private subjectConfig: SubjectConfigService ) {} - getMetaData(type, payload?: any): Promise { - const task = payload.task - const data = payload.data - switch (type) { - case SchemaType.GENERAL_HEALTH: - return Promise.resolve({ name: data.key, avsc: 'healthkit' }) - case SchemaType.ASSESSMENT: - return this.questionnaire - .getAssessmentForTask(task.type, task) - .then(assessment => assessment.questionnaire) - default: - return Promise.resolve({ name: type, avsc: 'questionnaire' }) - } - } - getKafkaObjectKey() { - return Promise.all([ - this.config.getSourceID(), - this.config.getProjectName(), - this.config.getParticipantLogin() - ]) - .then(([sourceId, projectName, participantName]) => ({ - sourceId, - userId: participantName.toString(), - projectId: projectName - })) - .then(observationKey => observationKey as KeyExport) + return this.subjectConfig + .getKafkaObservationKey() + .then( + payload => + ( + this.converterFactory + .getConverter(SchemaType.KEY) + .processData(payload) + ) + ) } getKafkaObjectValue(type, payload) { - switch (type) { - case SchemaType.GENERAL_HEALTH: - return payload.data as HealthkitSchemaType - case SchemaType.ASSESSMENT: - const Answer: AnswerValueExport = { - name: payload.task.name, - version: payload.data.scheduleVersion, - answers: payload.data.answers, - time: payload.data.time, - timeCompleted: payload.data.timeCompleted, - timeNotification: getSeconds({ milliseconds: payload.task.timestamp }) - } - return Answer - case SchemaType.COMPLETION_LOG: - const CompletionLog: CompletionLogValueExport = { - name: payload.name, - time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), - timeNotification: getSeconds({ - milliseconds: payload.timeNotification - }), - completionPercentage: { double: payload.percentage } - } - return CompletionLog - case SchemaType.TIMEZONE: - const ApplicationTimeZone: ApplicationTimeZoneValueExport = { - time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), - offset: getSeconds({ minutes: new Date().getTimezoneOffset() }) - } - return ApplicationTimeZone - case SchemaType.APP_EVENT: - const Event: EventValueExport = { - time: getSeconds({ milliseconds: this.getUniqueTimeNow() }), - eventType: payload.eventType.toUpperCase(), - questionnaireName: payload.questionnaireName, - metadata: this.utility.mapToObject(payload.metadata) - } - return Event - } - } - - convertToAvro(schema, value): any { - return AvroSchema.parse(schema).clone(value, { wrapUnions: true }) + return this.converterFactory.getConverter(type).processData(payload) } - getKafkaPayload(kafkaObject: any[], topic, baseURI): Promise { - if (!this.schemas[topic]) { - this.schemas[topic] = [ - this.getLatestKafkaSchemaVersion(topic + '-key', 'latest', baseURI), - this.getLatestKafkaSchemaVersion(topic + '-value', 'latest', baseURI) - ] - } - return Promise.all(this.schemas[topic]) - .then(([keySchemaMetadata, valueSchemaMetadata]) => { - const key = JSON.parse(keySchemaMetadata.schema) - const value = JSON.parse(valueSchemaMetadata.schema) - const records = kafkaObject.map(k => ({ - key: this.convertToAvro(key, k.key), - value: this.convertToAvro(value, k.value) + getKafkaPayload( + type, + kafkaKey, + kafkaObjects: any[], + cacheKeys: any[], + topics + ): Promise { + const valueConverter = this.converterFactory.getConverter(type) + return valueConverter.getKafkaTopic(kafkaObjects[0], topics).then(topic => + valueConverter.getSchemas(topic).then(schema => { + return Promise.all([ + this.converterFactory + .getConverter(SchemaType.KEY) + .convertToRecord(kafkaKey, topic, ''), + valueConverter.batchConvertToRecord(kafkaObjects, topic, schema) + ]).then(([key, records]) => ({ + topic, + cacheKey: cacheKeys, + record: { + key_schema_id: key.schema, + value_schema_id: records[0]['schema'], + records: records.map(r => ({ key: key.value, value: r['value'] })) + } })) - return { - key_schema_id: keySchemaMetadata.id, - value_schema_id: valueSchemaMetadata.id, - records - } - }) - .catch(e => { - this.schemas[topic] = null - throw e }) - } - - getRadarSpecifications(): Promise { - return this.remoteConfig - .read() - .then(config => - config.getOrDefault( - ConfigKeys.KAFKA_SPECIFICATION_URL, - DefaultSchemaSpecEndpoint - ) - ) - .then(url => this.http.get(url).toPromise()) - .then(res => YAML.parse(atob(res['content'])).data) - .catch(e => { - this.logger.error('Failed to get valid RADAR Schema specifications', e) - return null - }) - } - - getKafkaTopic( - specifications: any[] | null, - name, - avsc, - topics: string[] | null - ): Promise { - const type = name.toLowerCase() - - if (specifications) { - const spec = specifications.find(t => t.type.toLowerCase() == type) - // HEALTHKIT - if (avsc === 'healthkit') { - const topic = DefaultHealthkitTopicPrefix + name - return Promise.resolve(topic) - } - if (spec && spec.topic && this.topicExists(spec.topic, topics)) { - return Promise.resolve(spec.topic) - } - } - const questionnaireTopic = `${avsc}_${name}` - if (this.topicExists(questionnaireTopic, topics)) { - return Promise.resolve(questionnaireTopic) - } - const defaultTopic = this.GENERAL_TOPIC - if (this.topicExists(defaultTopic, topics)) { - return Promise.resolve(defaultTopic) - } - - return Promise.reject( - `No suitable topic found on server for questionnaire ${name}` ) } - - private topicExists(topic: string, topics: string[] | null) { - if (!topics || topics.includes(topic)) { - return true - } else { - this.logger.error( - `Cannot send data to specification topic ${topic} because target server does not have it`, - null - ) - return false - } - } - - getLatestKafkaSchemaVersion( - questionName, - version, - endPoint - ): Promise { - const versionStr = this.URI_version + version - const uri = endPoint + this.URI_schema + questionName + versionStr - - return this.http - .get(uri) - .toPromise() - .catch(e => { - throw this.logger.error('Failed to get latest Kafka schema versions', e) - }) - .then(obj => obj as SchemaMetadata) - } - - getUniqueTimeNow() { - return new Date().getTime() + Math.random() - } } diff --git a/src/app/core/services/misc/github-client.service.ts b/src/app/core/services/misc/github-client.service.ts index e1781eb1b..ec4d386cb 100644 --- a/src/app/core/services/misc/github-client.service.ts +++ b/src/app/core/services/misc/github-client.service.ts @@ -13,6 +13,8 @@ import { RemoteConfigService } from '../config/remote-config.service' @Injectable() export class GithubClient { + githubFetchStrategy: string + constructor( private appServerService: AppServerService, private util: Utility, @@ -38,13 +40,19 @@ export class GithubClient { } getFetchStrategy(): Promise { - return this.remoteConfig - .read() - .then(config => - config.getOrDefault( - ConfigKeys.GITHUB_FETCH_STRATEGY, - DefaultGithubFetchStrategy + if (!this.githubFetchStrategy) + return this.remoteConfig + .read() + .then(config => + config.getOrDefault( + ConfigKeys.GITHUB_FETCH_STRATEGY, + DefaultGithubFetchStrategy + ) ) - ) + .then(strategy => { + this.githubFetchStrategy = strategy + return strategy + }) + else return Promise.resolve(this.githubFetchStrategy) } } diff --git a/src/app/core/services/misc/localization.service.spec.ts b/src/app/core/services/misc/localization.service.spec.ts index f4947a616..167533d90 100644 --- a/src/app/core/services/misc/localization.service.spec.ts +++ b/src/app/core/services/misc/localization.service.spec.ts @@ -4,6 +4,7 @@ import { LogServiceMock, StorageServiceMock } from '../../../shared/testing/mock-services' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { LocalizationService } from './localization.service' import { LogService } from './log.service' @@ -15,7 +16,7 @@ describe('LocalizationService', () => { TestBed.configureTestingModule({ providers: [ LocalizationService, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock } ] }) diff --git a/src/app/core/services/misc/localization.service.ts b/src/app/core/services/misc/localization.service.ts index 2ca4a7243..7467f18f7 100644 --- a/src/app/core/services/misc/localization.service.ts +++ b/src/app/core/services/misc/localization.service.ts @@ -9,6 +9,7 @@ import { LocKeys } from '../../../shared/enums/localisations' import { StorageKeys } from '../../../shared/enums/storage' import { LanguageSetting } from '../../../shared/models/settings' import { MultiLanguageText } from '../../../shared/models/text' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable({ @@ -28,7 +29,7 @@ export class LocalizationService { private language: LanguageSetting = { ...this.defaultLanguage } private localeMoment: moment.Moment - constructor(private storage: StorageService) { + constructor(private storage: GlobalStorageService) { this.localeMoment = moment() this.update() this.updateLanguageSettings() @@ -88,9 +89,9 @@ export class LocalizationService { } translate(value: string) { + if (!value) return '' const loc = Localisations[value] if (!loc) { - console.log('Missing localization ' + value) return value } return this.chooseText(loc, value) diff --git a/src/app/core/services/notifications/fcm-notification.service.spec.ts b/src/app/core/services/notifications/fcm-notification.service.spec.ts index a468c1a76..a3fc41de4 100644 --- a/src/app/core/services/notifications/fcm-notification.service.spec.ts +++ b/src/app/core/services/notifications/fcm-notification.service.spec.ts @@ -17,6 +17,7 @@ import { SubjectConfigService } from '../config/subject-config.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' import { ScheduleService } from '../schedule/schedule.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { FcmNotificationService } from './fcm-notification.service' import { NotificationGeneratorService } from './notification-generator.service' @@ -31,7 +32,7 @@ describe('FcmNotificationService', () => { Platform, { provide: FirebaseX, useClass: FirebaseMock }, { provide: LogService, useClass: LogServiceMock }, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: SubjectConfigService, useClass: SubjectConfigServiceMock }, { provide: NotificationGeneratorService, diff --git a/src/app/core/services/notifications/fcm-notification.service.ts b/src/app/core/services/notifications/fcm-notification.service.ts index f59cf2914..381ef0f94 100644 --- a/src/app/core/services/notifications/fcm-notification.service.ts +++ b/src/app/core/services/notifications/fcm-notification.service.ts @@ -15,6 +15,7 @@ import { getSeconds } from '../../../shared/utilities/time' import { RemoteConfigService } from '../config/remote-config.service' import { SubjectConfigService } from '../config/subject-config.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { NotificationService } from './notification.service' @@ -26,7 +27,7 @@ export abstract class FcmNotificationService extends NotificationService { private tokenSubscription: Subscription constructor( - public store: StorageService, + public store: GlobalStorageService, public config: SubjectConfigService, public firebase: FirebaseX, public platform: Platform, diff --git a/src/app/core/services/notifications/fcm-rest-notification.service.ts b/src/app/core/services/notifications/fcm-rest-notification.service.ts index af0c35359..b09574322 100644 --- a/src/app/core/services/notifications/fcm-rest-notification.service.ts +++ b/src/app/core/services/notifications/fcm-rest-notification.service.ts @@ -31,6 +31,7 @@ import { SubjectConfigService } from '../config/subject-config.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' import { ScheduleService } from '../schedule/schedule.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { FcmNotificationService } from './fcm-notification.service' import { NotificationGeneratorService } from './notification-generator.service' @@ -45,7 +46,7 @@ export class FcmRestNotificationService extends FcmNotificationService { constructor( public notifications: NotificationGeneratorService, - public storage: StorageService, + public storage: GlobalStorageService, public schedule: ScheduleService, public config: SubjectConfigService, public firebase: FirebaseX, diff --git a/src/app/core/services/notifications/local-notification.service.spec.ts b/src/app/core/services/notifications/local-notification.service.spec.ts index 2eaee27ad..eb8d6dcfa 100644 --- a/src/app/core/services/notifications/local-notification.service.spec.ts +++ b/src/app/core/services/notifications/local-notification.service.spec.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/testing/mock-services' import { LogService } from '../misc/log.service' import { ScheduleService } from '../schedule/schedule.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { LocalNotificationService } from './local-notification.service' import { NotificationGeneratorService } from './notification-generator.service' @@ -23,7 +24,7 @@ describe('LocalNotificationService', () => { LocalNotificationService, { provide: LocalNotifications, useClass: LocalNotificationsMock }, { provide: LogService, useClass: LogServiceMock }, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: NotificationGeneratorService, useClass: NotificationGeneratorServiceMock diff --git a/src/app/core/services/notifications/local-notification.service.ts b/src/app/core/services/notifications/local-notification.service.ts index e26af6680..6377343c7 100644 --- a/src/app/core/services/notifications/local-notification.service.ts +++ b/src/app/core/services/notifications/local-notification.service.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/models/notification-handler' import { LogService } from '../misc/log.service' import { ScheduleService } from '../schedule/schedule.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { NotificationGeneratorService } from './notification-generator.service' import { NotificationService } from './notification.service' @@ -20,7 +21,7 @@ export class LocalNotificationService extends NotificationService { private notifications: NotificationGeneratorService, private schedule: ScheduleService, private localNotifications: LocalNotifications, - private store: StorageService, + private store: GlobalStorageService, private logger: LogService ) { super(store) diff --git a/src/app/core/services/notifications/notification-factory.service.ts b/src/app/core/services/notifications/notification-factory.service.ts index 0078c32e8..f123487fa 100644 --- a/src/app/core/services/notifications/notification-factory.service.ts +++ b/src/app/core/services/notifications/notification-factory.service.ts @@ -5,6 +5,7 @@ import { DefaultNotificationType } from '../../../../assets/data/defaultConfig' import { ConfigKeys } from '../../../shared/enums/config' import { NotificationMessagingType } from '../../../shared/models/notification-handler' import { RemoteConfigService } from '../config/remote-config.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { FcmRestNotificationService } from './fcm-rest-notification.service' import { LocalNotificationService } from './local-notification.service' @@ -19,7 +20,7 @@ export class NotificationFactoryService extends NotificationService { public localNotificationService: LocalNotificationService, private remoteConfig: RemoteConfigService, private platform: Platform, - private store: StorageService + private store: GlobalStorageService ) { super(store) } diff --git a/src/app/core/services/notifications/notification.service.spec.ts b/src/app/core/services/notifications/notification.service.spec.ts index 405735f81..e2b6f9271 100644 --- a/src/app/core/services/notifications/notification.service.spec.ts +++ b/src/app/core/services/notifications/notification.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing' import { StorageServiceMock } from '../../../shared/testing/mock-services' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { NotificationService } from './notification.service' @@ -11,7 +12,7 @@ describe('NotificationService', () => { TestBed.configureTestingModule({ providers: [ NotificationService, - { provide: StorageService, useClass: StorageServiceMock } + { provide: GlobalStorageService, useClass: StorageServiceMock } ] }) ) diff --git a/src/app/core/services/notifications/notification.service.ts b/src/app/core/services/notifications/notification.service.ts index c5f392997..37ce6d585 100644 --- a/src/app/core/services/notifications/notification.service.ts +++ b/src/app/core/services/notifications/notification.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core' import { StorageKeys } from '../../../shared/enums/storage' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable({ @@ -11,7 +12,7 @@ export abstract class NotificationService { LAST_NOTIFICATION_UPDATE: StorageKeys.LAST_NOTIFICATION_UPDATE, NOTIFICATION_MESSAGING_TYPE: StorageKeys.NOTIFICATION_MESSAGING_TYPE } - constructor(public storage: StorageService) {} + constructor(public storage: GlobalStorageService) {} abstract init() diff --git a/src/app/core/services/schedule/appserver-schedule.service.ts b/src/app/core/services/schedule/appserver-schedule.service.ts index 6a27b7695..e73e39f45 100644 --- a/src/app/core/services/schedule/appserver-schedule.service.ts +++ b/src/app/core/services/schedule/appserver-schedule.service.ts @@ -14,6 +14,7 @@ import { AppServerService } from '../app-server/app-server.service' import { QuestionnaireService } from '../config/questionnaire.service' import { LocalizationService } from '../misc/localization.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { ScheduleGeneratorService } from './schedule-generator.service' import { ScheduleService } from './schedule.service' @@ -21,7 +22,7 @@ import { ScheduleService } from './schedule.service' @Injectable() export class AppserverScheduleService extends ScheduleService { constructor( - private store: StorageService, + private store: GlobalStorageService, logger: LogService, private appServer: AppServerService, private localization: LocalizationService, @@ -102,9 +103,13 @@ export class AppserverScheduleService extends ScheduleService { .then(assessment => { const newTask = Object.assign(task, { reportedCompletion: !!task.completed, - nQuestions: assessment.questions.length, - warning: this.localization.chooseText(assessment.warn), - requiresInClinicCompletion: assessment.requiresInClinicCompletion, + nQuestions: assessment ? assessment.questions.length : 1, + warning: assessment + ? this.localization.chooseText(assessment.warn) + : '', + requiresInClinicCompletion: assessment + ? assessment.requiresInClinicCompletion + : false, notifications: [] }) return newTask diff --git a/src/app/core/services/schedule/local-schedule.service.ts b/src/app/core/services/schedule/local-schedule.service.ts index 72285e1ce..d9b7cd7cb 100644 --- a/src/app/core/services/schedule/local-schedule.service.ts +++ b/src/app/core/services/schedule/local-schedule.service.ts @@ -8,6 +8,7 @@ import { setDateTimeToMidnightEpoch } from '../../../shared/utilities/time' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { ScheduleGeneratorService } from './schedule-generator.service' import { ScheduleService } from './schedule.service' @@ -15,7 +16,7 @@ import { ScheduleService } from './schedule.service' @Injectable() export class LocalScheduleService extends ScheduleService { constructor( - private store: StorageService, + private store: GlobalStorageService, logger: LogService, private scheduleGenerator: ScheduleGeneratorService ) { diff --git a/src/app/core/services/schedule/schedule-factory.service.ts b/src/app/core/services/schedule/schedule-factory.service.ts index 92b570642..97683a1cc 100644 --- a/src/app/core/services/schedule/schedule-factory.service.ts +++ b/src/app/core/services/schedule/schedule-factory.service.ts @@ -6,6 +6,7 @@ import { Assessment, AssessmentType } from '../../../shared/models/assessment' import { SchedulerType } from '../../../shared/models/notification-handler' import { RemoteConfigService } from '../config/remote-config.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { AppserverScheduleService } from './appserver-schedule.service' import { LocalScheduleService } from './local-schedule.service' @@ -19,7 +20,7 @@ export class ScheduleFactoryService extends ScheduleService { public localScheduleService: LocalScheduleService, public appServerScheduleSerice: AppserverScheduleService, private remoteConfig: RemoteConfigService, - private store: StorageService, + private store: GlobalStorageService, logger: LogService ) { super(store, logger) diff --git a/src/app/core/services/schedule/schedule.service.spec.ts b/src/app/core/services/schedule/schedule.service.spec.ts index ee0538207..cc73033ab 100644 --- a/src/app/core/services/schedule/schedule.service.spec.ts +++ b/src/app/core/services/schedule/schedule.service.spec.ts @@ -6,6 +6,7 @@ import { StorageServiceMock } from '../../../shared/testing/mock-services' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { ScheduleGeneratorService } from './schedule-generator.service' import { ScheduleService } from './schedule.service' @@ -21,7 +22,7 @@ describe('ScheduleService', () => { provide: ScheduleGeneratorService, useClass: ScheduleGeneratorServiceMock }, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock } ] }) diff --git a/src/app/core/services/schedule/schedule.service.ts b/src/app/core/services/schedule/schedule.service.ts index fb580e5b8..f8fa9b135 100644 --- a/src/app/core/services/schedule/schedule.service.ts +++ b/src/app/core/services/schedule/schedule.service.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/utilities/time' import { AppServerService } from '../app-server/app-server.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' import { ScheduleGeneratorService } from './schedule-generator.service' @@ -26,7 +27,7 @@ export abstract class ScheduleService { changeDetectionEmitter: EventEmitter = new EventEmitter() constructor( - protected storage: StorageService, + protected storage: GlobalStorageService, protected logger: LogService ) {} diff --git a/src/app/core/services/storage/storage.service.spec.ts b/src/app/core/services/storage/global-storage.service.spec.ts similarity index 100% rename from src/app/core/services/storage/storage.service.spec.ts rename to src/app/core/services/storage/global-storage.service.spec.ts diff --git a/src/app/core/services/storage/global-storage.service.ts b/src/app/core/services/storage/global-storage.service.ts new file mode 100755 index 000000000..2d75e2b1c --- /dev/null +++ b/src/app/core/services/storage/global-storage.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core' +import { Platform } from '@ionic/angular' +import { Storage } from '@ionic/storage' + +import { LogService } from '../misc/log.service' +import { StorageService } from './storage.service' + +@Injectable() +export class GlobalStorageService extends StorageService { + constructor( + public storage: Storage, + public logger: LogService, + public platform: Platform + ) { + super(storage, logger, platform) + this.platform.ready().then(() => { + this.prepare().then(() => + this.logger.log('Global configuration', this.global) + ) + }) + } +} diff --git a/src/app/core/services/storage/health-storage.service.ts b/src/app/core/services/storage/health-storage.service.ts new file mode 100755 index 000000000..bbe224448 --- /dev/null +++ b/src/app/core/services/storage/health-storage.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@angular/core' +import { Platform } from '@ionic/angular' +import { Storage } from '@ionic/storage' +import { Observable, Subject, throwError as observableThrowError } from 'rxjs' +import { filter, startWith, switchMap } from 'rxjs/operators' + +import { StorageKeys } from '../../../shared/enums/storage' +import { LogService } from '../misc/log.service' +import { StorageService } from './storage.service' + +@Injectable() +export class HealthStorageService extends StorageService { + global: { [key: string]: any } = {} + + constructor( + public logger: LogService, + public healthStorage: Storage, + public platform: Platform + ) { + super(healthStorage, logger, platform) + this.platform.ready().then(() => { + this.healthStorage = new Storage({ + name: '__health_db', + storeName: '_data', + driverOrder: ['sqlite', 'indexeddb', 'websql', 'localstorage'] + }) + + this.prepare().then(() => + this.logger.log('Health configuration', this.global) + ) + }) + } + + getStorageState() { + return this.healthStorage.ready() + } + + set(key: StorageKeys, value: any): Promise { + const keys = Object.keys(value) + return Promise.all( + keys.map(k => { + this.global[k] = value[k] + return this.healthStorage.set(k, value[k]) + }) + ) + } + + get(key: StorageKeys): Promise { + const k = key.toString() + if (this.global !== undefined) { + return Promise.resolve(this.global) + } + } + + observe(key: StorageKeys): Observable { + return this.keyUpdates.pipe( + startWith(key), + filter(k => k === key || k === null), + switchMap(k => this.get(k)) + ) + } + + remove(keys: any) { + return Promise.all( + keys.map(k => + this.healthStorage.remove(k).catch(error => this.handleError(error)) + ) + ).then(() => keys.map(k => delete this.global[k])) + } + + getAllKeys(): Promise { + return this.healthStorage.keys() + } + + prepare() { + return this.healthStorage + .keys() + .then(keys => + Promise.all( + keys.map(k => + this.healthStorage.get(k).then(v => (this.global[k] = v)) + ) + ) + ) + .then(() => 'Health Store set') + } + + clear() { + this.global = {} + return this.healthStorage.clear().then(() => this.keyUpdates.next(null)) + } +} diff --git a/src/app/core/services/storage/storage.service.ts b/src/app/core/services/storage/storage.service.ts index a5e8e95b9..f195ddca7 100755 --- a/src/app/core/services/storage/storage.service.ts +++ b/src/app/core/services/storage/storage.service.ts @@ -8,34 +8,17 @@ import { StorageKeys } from '../../../shared/enums/storage' import { LogService } from '../misc/log.service' @Injectable() -export class StorageService { +export abstract class StorageService { global: { [key: string]: any } = {} healthGlobal: { [key: string]: any } = {} - private readonly keyUpdates: Subject + public readonly keyUpdates: Subject constructor( - private storage: Storage, - private logger: LogService, - private healthStorage: Storage, - private platform: Platform + public storage: Storage, + public logger: LogService, + public platform: Platform ) { - this.platform.ready().then(() => { - this.healthStorage = new Storage({ - name: '__health_db', - storeName: '_data', - driverOrder: ['sqlite', 'indexeddb', 'websql', 'localstorage'] - }) - - this.prepare() - .then(() => this.logger.log('Global configuration', this.global)) - .then(() => - this.prepareHealth().then(() => - this.logger.log('Global configuration', this.healthGlobal) - ) - ) - }) - this.keyUpdates = new Subject() } @@ -52,32 +35,6 @@ export class StorageService { }) } - resetHealthData(): Promise { - this.healthGlobal = {} - return this.healthStorage.clear() - } - - setHealthData(value: any): Promise { - const keys = Object.keys(value) - return Promise.all( - keys.map(k => { - this.healthGlobal[k] = value[k] - return this.healthStorage.set(k, value[k]) - }) - ) - } - - removeHealthData(keys: any[]) { - return Promise.all( - keys.map(k => - this.healthStorage - .remove(k) - .then(() => delete this.healthGlobal[k]) - .catch(error => this.handleError(error)) - ) - ) - } - push(key: StorageKeys, value: any): Promise { if (this.global[key.toString()]) this.global[key.toString()].push(value) else this.global[key.toString()] = [value] @@ -102,13 +59,6 @@ export class StorageService { } } - getHealthData(key: StorageKeys): Promise { - const k = key.toString() - if (this.healthGlobal !== undefined) { - return Promise.resolve(this.healthGlobal) - } - } - observe(key: StorageKeys): Observable { return this.keyUpdates.pipe( startWith(key), @@ -143,25 +93,12 @@ export class StorageService { .then(() => 'Store set') } - prepareHealth() { - return this.healthStorage - .keys() - .then(keys => - Promise.all( - keys.map(k => - this.healthStorage.get(k).then(v => (this.healthGlobal[k] = v)) - ) - ) - ) - .then(() => 'Health Store set') - } - clear() { this.global = {} return this.storage.clear().then(() => this.keyUpdates.next(null)) } - private handleError(error: any) { + handleError(error: any) { const errMsg = error.message ? error.message : error.status diff --git a/src/app/core/services/token/token.service.spec.ts b/src/app/core/services/token/token.service.spec.ts index bb0fccca9..d982ac954 100644 --- a/src/app/core/services/token/token.service.spec.ts +++ b/src/app/core/services/token/token.service.spec.ts @@ -11,7 +11,7 @@ import { } from '../../../shared/testing/mock-services' import { RemoteConfigService } from '../config/remote-config.service' import { LogService } from '../misc/log.service' -import { StorageService } from '../storage/storage.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { TokenService } from './token.service' describe('TokenService', () => { @@ -26,7 +26,7 @@ describe('TokenService', () => { Platform, { provide: RemoteConfigService, useClass: RemoteConfigServiceMock }, { provide: JwtHelperService, useClass: JwtHelperServiceMock }, - { provide: StorageService, useClass: StorageServiceMock }, + { provide: GlobalStorageService, useClass: StorageServiceMock }, { provide: LogService, useClass: LogServiceMock } ] }) diff --git a/src/app/core/services/token/token.service.ts b/src/app/core/services/token/token.service.ts index 41ed51107..135171186 100644 --- a/src/app/core/services/token/token.service.ts +++ b/src/app/core/services/token/token.service.ts @@ -18,6 +18,7 @@ import { OAuthToken } from '../../../shared/models/token' import { getSeconds } from '../../../shared/utilities/time' import { RemoteConfigService } from '../config/remote-config.service' import { LogService } from '../misc/log.service' +import { GlobalStorageService } from '../storage/global-storage.service' import { StorageService } from '../storage/storage.service' @Injectable({ @@ -33,7 +34,7 @@ export class TokenService { constructor( public http: HttpClient, - public storage: StorageService, + public storage: GlobalStorageService, private jwtHelper: JwtHelperService, private remoteConfig: RemoteConfigService, private logger: LogService, diff --git a/src/app/core/services/usage/firebase-analytics.service.ts b/src/app/core/services/usage/firebase-analytics.service.ts index 5973759dd..4b5b641e7 100644 --- a/src/app/core/services/usage/firebase-analytics.service.ts +++ b/src/app/core/services/usage/firebase-analytics.service.ts @@ -19,7 +19,7 @@ export class FirebaseAnalyticsService extends AnalyticsService { } logEvent(event: string, params: { [key: string]: string }): Promise { - this.logger.log('Firebase Event', event) + // this.logger.log('Firebase Event', event) if (!this.platform.is('cordova')) return Promise.resolve('Could not load firebase') @@ -41,7 +41,7 @@ export class FirebaseAnalyticsService extends AnalyticsService { return this.firebase .logEvent(event, cleanParams) .then((res: any) => { - this.logger.log('firebase analytics service', res) + // this.logger.log('firebase analytics service', res) return res }) .catch((error: any) => { diff --git a/src/app/core/services/usage/usage.service.ts b/src/app/core/services/usage/usage.service.ts index 48cff569e..4b21b8a61 100644 --- a/src/app/core/services/usage/usage.service.ts +++ b/src/app/core/services/usage/usage.service.ts @@ -21,8 +21,7 @@ export class UsageService { sendEventToKafka(payload) { return this.kafka - .prepareKafkaObjectAndSend(SchemaType.APP_EVENT, payload, true) - .then(event => this.kafka.storeInCache(event)) + .prepareKafkaObjectAndStore(SchemaType.APP_EVENT, payload) .then((res: any) => this.logger.log('usage service', 'send success')) .catch((error: any) => this.logger.error('usage service', error)) } @@ -74,17 +73,11 @@ export class UsageService { } sendCompletionLog(task, percent) { - return this.kafka - .prepareKafkaObjectAndSend( - SchemaType.COMPLETION_LOG, - { - name: task.name, - percentage: percent, - timeNotification: task.timestamp - }, - true - ) - .then(log => this.kafka.storeInCache(log)) + return this.kafka.prepareKafkaObjectAndStore(SchemaType.COMPLETION_LOG, { + name: task.name, + percentage: percent, + timeNotification: task.timestamp + }) } setPage(component) { diff --git a/src/app/pages/home/services/tasks.service.ts b/src/app/pages/home/services/tasks.service.ts index 5b713bc61..644b9645b 100644 --- a/src/app/pages/home/services/tasks.service.ts +++ b/src/app/pages/home/services/tasks.service.ts @@ -36,7 +36,6 @@ export class TasksService { } init() { - console.log(this.schedule.isInitialised()) return this.schedule.isInitialised() ? Promise.resolve() : this.schedule.init() diff --git a/src/app/pages/on-demand/containers/on-demand-page.component.scss b/src/app/pages/on-demand/containers/on-demand-page.component.scss index 55659bb73..3fca9cab1 100644 --- a/src/app/pages/on-demand/containers/on-demand-page.component.scss +++ b/src/app/pages/on-demand/containers/on-demand-page.component.scss @@ -1,9 +1,20 @@ ion-header .toolbar-background { - background-color: var(--ion-color-secondary); + --background: var(--ion-color-secondary); } -.toolbar-title { - color: var(--ion-font); +ion-toolbar { + --background: var(--ion-color-secondary); +} + +ion-toolbar > ion-button { + --border-width: 0 !important; + --background: transparent; + --padding-start: 12px; + --padding-end: 12px; +} + +ion-title { + margin: -4px; } .date { diff --git a/src/app/pages/on-demand/containers/on-demand-page.component.spec.ts b/src/app/pages/on-demand/containers/on-demand-page.component.spec.ts index ced15920d..bebde8a0f 100644 --- a/src/app/pages/on-demand/containers/on-demand-page.component.spec.ts +++ b/src/app/pages/on-demand/containers/on-demand-page.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing' import { IonicModule, NavController } from '@ionic/angular' import { PipesModule } from 'src/app/shared/pipes/pipes.module' +import { DefaultOnDemandAssessmentLabel } from '../../../../assets/data/defaultConfig' import { AppModule } from '../../../app.module' import { OnDemandService } from '../services/on-demand.service' import { OnDemandPageComponent } from './on-demand-page.component' @@ -31,4 +32,11 @@ describe('OnDemandPageComponent', () => { }) }) -class OnDemandServiceMock {} +class OnDemandServiceMock { + getAssessements() { + return Promise.resolve([]) + } + getOnDemandPageLabel() { + return Promise.resolve(DefaultOnDemandAssessmentLabel) + } +} diff --git a/src/app/pages/on-demand/containers/on-demand-page.component.ts b/src/app/pages/on-demand/containers/on-demand-page.component.ts index 5bd25ba6d..39c06195f 100644 --- a/src/app/pages/on-demand/containers/on-demand-page.component.ts +++ b/src/app/pages/on-demand/containers/on-demand-page.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core' +import { Component, OnInit } from '@angular/core' import { NavController } from '@ionic/angular' import { Assessment } from '../../../shared/models/assessment' @@ -10,7 +10,7 @@ import { OnDemandService } from '../services/on-demand.service' templateUrl: 'on-demand-page.component.html', styleUrls: ['on-demand-page.component.scss'] }) -export class OnDemandPageComponent { +export class OnDemandPageComponent implements OnInit { scrollHeight: number = 500 assessments: Assessment[] title: Promise @@ -20,7 +20,7 @@ export class OnDemandPageComponent { private onDemandService: OnDemandService ) {} - ionViewDidLoad() { + ngOnInit() { this.onDemandService.getAssessements().then(assessments => { this.assessments = assessments.sort((a, b) => a.order - b.order) }) diff --git a/src/app/pages/on-demand/on-demand.module.ts b/src/app/pages/on-demand/on-demand.module.ts index e755244d1..3aa589aa7 100644 --- a/src/app/pages/on-demand/on-demand.module.ts +++ b/src/app/pages/on-demand/on-demand.module.ts @@ -1,16 +1,26 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' import { IonicModule } from '@ionic/angular' import { PipesModule } from '../../shared/pipes/pipes.module' +import { AuthGuard } from '../auth/services/auth.guard' import { OnDemandPageComponent } from './containers/on-demand-page.component' import { OnDemandService } from './services/on-demand.service' +const routes: Routes = [ + { + path: '', + component: OnDemandPageComponent, + canActivate: [AuthGuard] + } +] @NgModule({ imports: [ CommonModule, PipesModule, - IonicModule.forRoot() + IonicModule.forRoot(), + RouterModule.forChild(routes) ], declarations: [OnDemandPageComponent], providers: [OnDemandService] diff --git a/src/app/pages/pages.module.ts b/src/app/pages/pages.module.ts index 260d7a2e5..58df61a75 100644 --- a/src/app/pages/pages.module.ts +++ b/src/app/pages/pages.module.ts @@ -23,6 +23,8 @@ import { LocalScheduleService } from '../core/services/schedule/local-schedule.s import { ScheduleFactoryService } from '../core/services/schedule/schedule-factory.service' import { ScheduleGeneratorService } from '../core/services/schedule/schedule-generator.service' import { ScheduleService } from '../core/services/schedule/schedule.service' +import { GlobalStorageService } from '../core/services/storage/global-storage.service' +import { HealthStorageService } from '../core/services/storage/health-storage.service' import { StorageService } from '../core/services/storage/storage.service' import { TokenService } from '../core/services/token/token.service' import { AnalyticsService } from '../core/services/usage/analytics.service' @@ -66,7 +68,9 @@ import { SplashModule } from './splash/splash.module' LocalScheduleService, AppserverScheduleService, ScheduleGeneratorService, - StorageService, + GlobalStorageService, + HealthStorageService, + { provide: StorageService, useClass: GlobalStorageService }, TranslatePipe, UsageService, SchemaService, diff --git a/src/app/pages/questions/components/finish/finish.component.ts b/src/app/pages/questions/components/finish/finish.component.ts index 16fbb8a5f..dcc32e59f 100755 --- a/src/app/pages/questions/components/finish/finish.component.ts +++ b/src/app/pages/questions/components/finish/finish.component.ts @@ -49,7 +49,7 @@ export class FinishComponent implements OnChanges { if (this.isShown) { this.onQuestionnaireCompleted() this.usage.setPage(this.constructor.name) - setTimeout(() => (this.showDoneButton = true), 15000) + setTimeout(() => (this.showDoneButton = true), 10000) } } diff --git a/src/app/pages/questions/components/question/health-input/health-input.component.html b/src/app/pages/questions/components/question/health-input/health-input.component.html index 98b9ec2ae..a06a3fa5d 100644 --- a/src/app/pages/questions/components/question/health-input/health-input.component.html +++ b/src/app/pages/questions/components/question/health-input/health-input.component.html @@ -13,11 +13,11 @@ - - The device doesn't support !!! - - - {{health_display_time}} - - + + The device doesn't support !!! + + + {{ health_display_time }} + + diff --git a/src/app/pages/questions/components/question/health-input/health-input.component.ts b/src/app/pages/questions/components/question/health-input/health-input.component.ts index a6f1c18d2..fec39ae2f 100644 --- a/src/app/pages/questions/components/question/health-input/health-input.component.ts +++ b/src/app/pages/questions/components/question/health-input/health-input.component.ts @@ -1,10 +1,8 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' -import { Health } from '@awesome-cordova-plugins/health/ngx' -import { StorageService } from 'src/app/core/services/storage/storage.service' -import { StorageKeys } from 'src/app/shared/enums/storage' import { Health_Requirement, Question } from 'src/app/shared/models/question' import { Response } from '../../../../../shared/models/question' +import { HealthkitService } from '../../../services/healthkit.service' @Component({ selector: 'health-input', @@ -21,127 +19,30 @@ export class HealthInputComponent implements OnInit { @Input() health_question: Question - health_value: any - health_display: any - health_display_time: any - health_time: any - not_support = true - // the interval days for first query - defaultInterval = 100 + health_display: string + health_display_time: string + isSupported = false - // the bucket for aggregated query - defaultBucket = 'day' - MIN_POLL_TIMESTAMP = - new Date().getTime() - this.defaultInterval * 24 * 60 * 60 * 1000 - - constructor(private health: Health, private storage: StorageService) {} + constructor(private healthKitService: HealthkitService) {} ngOnInit() { - this.initLastPollTimes() - let requireField = [] - if ( - this.health_question.field_name === 'blood_pressure_systolic' || - this.health_question.field_name === 'blood_pressure_diastolic' - ) { - requireField = ['blood_pressure'] - } else { - requireField = [this.health_question.field_name] - } - console.log('Requesting permissions: ' + requireField) - const healthDataType = requireField[0] - this.health - .isAvailable() - .then((available: boolean) => { - if (available) { - this.not_support = false - console.log('Requesting data..') - this.loadData(healthDataType).then(data => { - this.onInputChange(data) - }) - } - }) - .catch(e => console.log(e)) - } - - initLastPollTimes() { - return this.storage - .get(StorageKeys.HEALTH_LAST_POLL_TIMES) - .then(dic => - dic ? [] : this.storage.set(StorageKeys.HEALTH_LAST_POLL_TIMES, {}) - ) + this.loadData() } - onInputChange(data) { - const event = { - value: this.health_value, - time: this.health_time - } - this.valueChange.emit(data) - } - - loadData(healthDataType) { - return Promise.all([ - this.health.requestAuthorization([ - { - read: [healthDataType] //read only permission - } - ]), - this.storage.get(StorageKeys.HEALTH_LAST_POLL_TIMES) - ]) - .then(([, dic]) => { - let lastPollTime = - dic && dic[healthDataType] - ? new Date(dic[healthDataType]) - : new Date(this.MIN_POLL_TIMESTAMP) - const endTime = new Date() - return this.query(lastPollTime, endTime, healthDataType).then(res => { - if (res.length) { - dic[healthDataType] = new Date(res[0].endDate).getTime() - console.log(dic) - this.storage.set(StorageKeys.HEALTH_LAST_POLL_TIMES, dic) - } - return res + loadData() { + let healthDataType = this.health_question.field_name + if (this.health_question.field_name.includes('blood_pressure')) + healthDataType = 'blood_pressure' + this.healthKitService.checkHealthkitSupported().then(res => { + this.isSupported = res + if (this.isSupported) { + this.health_display_time = new Date().toLocaleDateString() + this.health_display = 'Loading..' + this.healthKitService.loadData(healthDataType).then(data => { + this.health_display = data.length + ' records' + return this.valueChange.emit(data) }) - }) - .catch(e => { - console.log(e) - return null - }) - } - - query(queryStartTime: Date, queryEndTime: Date, dataType: string) { - // !Will have to remove activity here, since each activity acutally contains more payload - // !Set the acitiviy to be UNKNOWN for now to avoid schema confliction - return this.health - .query({ - // put the lastDate in StartDate - startDate: queryStartTime, - endDate: queryEndTime, // now - dataType: dataType, - limit: 1000 - }) - .then(res => { - console.log('Field type: ' + dataType) - if (!res.length) { - this.health_value = null - this.health_display = 'No data for today' - this.health_display_time = new Date().toLocaleDateString() - this.health_time = Math.floor(res[0].startDate.getTime() / 1000) - } else { - if (dataType === 'date_of_birth') { - const value = res[0].value as any - this.health_value = value.day + '/' + value.month + '/' + value.year - this.health_display = this.health_value - } else { - this.health_value = parseFloat(res[0].value) - this.health_display = - this.health_value.toFixed(2) + ' ' + res[0].unit - } - // deal with time - this.health_display_time = res[0].startDate.toLocaleDateString() - this.health_time = Math.floor(res[0].startDate.getTime() / 1000) - } - return res - }) + } + }) } } diff --git a/src/app/pages/questions/components/question/notes-input/notes-input.component.html b/src/app/pages/questions/components/question/notes-input/notes-input.component.html new file mode 100644 index 000000000..80516977e --- /dev/null +++ b/src/app/pages/questions/components/question/notes-input/notes-input.component.html @@ -0,0 +1,12 @@ + + + diff --git a/src/app/pages/questions/components/question/notes-input/notes-input.component.scss b/src/app/pages/questions/components/question/notes-input/notes-input.component.scss new file mode 100644 index 000000000..d2da07693 --- /dev/null +++ b/src/app/pages/questions/components/question/notes-input/notes-input.component.scss @@ -0,0 +1,15 @@ +ion-item { + margin-bottom: 8px !important; +} + +.input { + padding-left: 0; + width: 80vw; + border-start-end-radius: 20px; + border-start-start-radius: 20px; + border-bottom: 2px solid white; + background-color: var(--cl-primary-10); + color: var(--ion-font); + font-size: 18px; + line-height: 1.35em; +} diff --git a/src/app/pages/questions/components/question/notes-input/notes-input.component.ts b/src/app/pages/questions/components/question/notes-input/notes-input.component.ts new file mode 100644 index 000000000..3eb7d5840 --- /dev/null +++ b/src/app/pages/questions/components/question/notes-input/notes-input.component.ts @@ -0,0 +1,44 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { Keyboard } from '@ionic-native/keyboard/ngx' +import { ModalController } from '@ionic/angular' + +import { LocalizationService } from '../../../../../core/services/misc/localization.service' +import { KeyboardEventType } from '../../../../../shared/enums/events' + +@Component({ + selector: 'notes-input', + templateUrl: 'notes-input.component.html', + styleUrls: ['notes-input.component.scss'] +}) +export class NotesInputComponent implements OnInit { + @Output() + valueChange: EventEmitter = new EventEmitter() + @Output() + keyboardEvent: EventEmitter = new EventEmitter() + @Input() + type: string + @Input() + currentlyShown: boolean + + textValue = '' + value = {} + + constructor( + private localization: LocalizationService, + public modalCtrl: ModalController, + private keyboard: Keyboard + ) {} + + ngOnInit() {} + + emitAnswer(value) { + this.valueChange.emit(this.textValue) + } + + emitKeyboardEvent(value) { + value = value.toLowerCase() + if (value == KeyboardEventType.ENTER) this.keyboard.hide() + + this.keyboardEvent.emit(value) + } +} diff --git a/src/app/pages/questions/components/question/question.component.html b/src/app/pages/questions/components/question/question.component.html index 6389ef0cc..46ad8635e 100755 --- a/src/app/pages/questions/components/question/question.component.html +++ b/src/app/pages/questions/components/question/question.component.html @@ -151,6 +151,22 @@

(valueChange)="emitAnswer($event)" > + + + + +
diff --git a/src/app/pages/questions/components/question/question.component.ts b/src/app/pages/questions/components/question/question.component.ts index a89890857..057f0e0b0 100755 --- a/src/app/pages/questions/components/question/question.component.ts +++ b/src/app/pages/questions/components/question/question.component.ts @@ -9,9 +9,7 @@ import { ViewChild } from '@angular/core' import { Dialogs } from '@ionic-native/dialogs/ngx' -import { Keyboard } from '@ionic-native/keyboard/ngx' import { Vibration } from '@ionic-native/vibration/ngx' -import { IonContent } from '@ionic/angular' import * as smoothscroll from 'smoothscroll-polyfill' import { @@ -84,7 +82,10 @@ export class QuestionComponent implements OnInit, OnChanges { QuestionType.audio, QuestionType.descriptive ]) - MATRIX_INPUT_SET: Set = new Set([QuestionType.matrix_radio,QuestionType.health]) + MATRIX_INPUT_SET: Set = new Set([ + QuestionType.matrix_radio, + QuestionType.health + ]) // Input set where height is set to auto AUTO_HEIGHT_INPUT_SET: Set = new Set([ diff --git a/src/app/pages/questions/components/question/question.module.ts b/src/app/pages/questions/components/question/question.module.ts index 8b66b74f1..d9d257e29 100644 --- a/src/app/pages/questions/components/question/question.module.ts +++ b/src/app/pages/questions/components/question/question.module.ts @@ -9,8 +9,10 @@ import { WheelSelectorComponent } from '../wheel-selector/wheel-selector.compone import { AudioInputComponent } from './audio-input/audio-input.component' import { CheckboxInputComponent } from './checkbox-input/checkbox-input.component' import { DescriptiveInputComponent } from './descriptive-input/descriptive-input.component' +import { HealthInputComponent } from './health-input/health-input.component' import { InfoScreenComponent } from './info-screen/info-screen.component' import { MatrixRadioInputComponent } from './matrix-radio-input/matrix-radio-input.component' +import { NotesInputComponent } from './notes-input/notes-input.component' import { QuestionComponent } from './question.component' import { RadioInputComponent } from './radio-input/radio-input.component' import { RangeInfoInputComponent } from './range-info-input/range-info-input.component' @@ -18,7 +20,7 @@ import { RangeInputComponent } from './range-input/range-input.component' import { SliderInputComponent } from './slider-input/slider-input.component' import { TextInputComponent } from './text-input/text-input.component' import { TimedTestComponent } from './timed-test/timed-test.component' -import { HealthInputComponent } from './health-input/health-input.component' +import { WebInputComponent } from './web-input/web-input.component' const COMPONENTS = [ QuestionComponent, @@ -34,7 +36,9 @@ const COMPONENTS = [ WheelSelectorComponent, DescriptiveInputComponent, MatrixRadioInputComponent, - HealthInputComponent + HealthInputComponent, + WebInputComponent, + NotesInputComponent ] @NgModule({ diff --git a/src/app/pages/questions/components/question/web-input/web-input.component.html b/src/app/pages/questions/components/question/web-input/web-input.component.html new file mode 100755 index 000000000..e3e8c7ba3 --- /dev/null +++ b/src/app/pages/questions/components/question/web-input/web-input.component.html @@ -0,0 +1,27 @@ + +
+ Please enter a valid NHS number. + + + + + Login to NHS + +
+
diff --git a/src/app/pages/questions/components/question/web-input/web-input.component.scss b/src/app/pages/questions/components/question/web-input/web-input.component.scss new file mode 100755 index 000000000..56f0caa92 --- /dev/null +++ b/src/app/pages/questions/components/question/web-input/web-input.component.scss @@ -0,0 +1,63 @@ +.info-card { + padding: 16px; + height: 92%; + -webkit-border-radius: 8px; + border-radius: 8px; + background-color: var(--cl-primary-20); + overflow: scroll; +} + +ion-label { + margin-left: 4px; + font-size: 12px; +} + +ion-content { + width: 75vw; + --background: transparent; + mask-image: -webkit-gradient( + linear, + left top, + left bottom, + from(rgba(0, 0, 0, 1)), + to(rgba(0, 0, 0, 0.25)) + ); + overflow: scroll; +} + +.items { + padding-bottom: 32%; + overflow: scroll; +} + +.info-heading { + opacity: 0.6; +} + +.image { + margin: auto; + width: 104px; + height: auto; +} + +.image-container { + text-align: center; +} + +.icon { + position: relative; + float: right; + margin: -36px 8px; + width: inherit; + text-align: right; +} + +ion-icon { + color: var(--cl-light-40); + font-size: 40px; +} + +ion-button { + margin-top: 24px; + margin-bottom: 24px; +} diff --git a/src/app/pages/questions/components/question/web-input/web-input.component.ts b/src/app/pages/questions/components/question/web-input/web-input.component.ts new file mode 100755 index 000000000..eb9558835 --- /dev/null +++ b/src/app/pages/questions/components/question/web-input/web-input.component.ts @@ -0,0 +1,100 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + ViewChild +} from '@angular/core' +import { + InAppBrowser, + InAppBrowserOptions +} from '@awesome-cordova-plugins/in-app-browser/ngx' +import { Keyboard } from '@ionic-native/keyboard/ngx' +import { KeyboardEventType } from 'src/app/shared/enums/events' +import { WebInputType } from 'src/app/shared/models/question' +import { isValidNHSId } from 'src/app/shared/utilities/form-validators' + +@Component({ + selector: 'web-input', + templateUrl: 'web-input.component.html', + styleUrls: ['web-input.component.scss'] +}) +export class WebInputComponent implements OnInit { + @Output() + valueChange: EventEmitter = new EventEmitter() + @Output() + keyboardEvent: EventEmitter = new EventEmitter() + @Input() + text: string + @Input() + currentlyShown: boolean + @Input() + type: string + + url: string + validator + textValue = '' + inputValid = true + NHS_URL = 'https://www.nhs.uk/nhs-services/online-services/find-nhs-number/' + + browserOptions: InAppBrowserOptions = { + location: 'no', + hidenavigationbuttons: 'yes', + hideurlbar: 'yes', + toolbarcolor: '#6d9aa5', + closebuttoncolor: '#ffffff' + } + + constructor( + private theInAppBrowser: InAppBrowser, + private keyboard: Keyboard + ) {} + + ngOnInit() { + this.url = this.getWebUrl() + this.validator = this.getInputValidator() + } + + emitAnswer(value) { + const valid = this.validator(this.textValue) + if (valid) { + this.valueChange.emit(this.textValue) + this.inputValid = true + } else this.inputValid = false + } + + emitKeyboardEvent(value) { + value = value.toLowerCase() + if (value == KeyboardEventType.ENTER) this.keyboard.hide() + + this.keyboardEvent.emit(value) + } + + openUrl() { + this.openWithInAppBrowser(this.url) + } + + openWithInAppBrowser(url: string) { + this.theInAppBrowser.create(url, '_blank', this.browserOptions) + } + + getWebUrl() { + switch(this.type) { + case WebInputType.NHS: + return this.NHS_URL + default: + return this.NHS_URL + } + } + + getInputValidator() { + switch(this.type) { + case WebInputType.NHS: + return isValidNHSId + default: + return isValidNHSId + } + } +} diff --git a/src/app/pages/questions/containers/questions-page.component.ts b/src/app/pages/questions/containers/questions-page.component.ts index 6c1278e79..62d7bb982 100644 --- a/src/app/pages/questions/containers/questions-page.component.ts +++ b/src/app/pages/questions/containers/questions-page.component.ts @@ -187,7 +187,6 @@ export class QuestionsPageComponent implements OnInit { } slideQuestion() { - console.log(this.currentQuestionGroupId) this.slides .lockSwipes(false) .then(() => this.slides.slideTo(this.currentQuestionGroupId, 300)) diff --git a/src/app/pages/questions/services/finish-task.service.ts b/src/app/pages/questions/services/finish-task.service.ts index 0ab688ce5..21c4091e1 100644 --- a/src/app/pages/questions/services/finish-task.service.ts +++ b/src/app/pages/questions/services/finish-task.service.ts @@ -4,14 +4,11 @@ import { HealthkitStringDataType } from 'src/app/shared/models/health' -import { AppConfigService } from '../../../core/services/config/app-config.service' import { ConfigService } from '../../../core/services/config/config.service' import { KafkaService } from '../../../core/services/kafka/kafka.service' -import { LogService } from '../../../core/services/misc/log.service' import { ScheduleService } from '../../../core/services/schedule/schedule.service' import { AssessmentType } from '../../../shared/models/assessment' import { SchemaType } from '../../../shared/models/kafka' -import { QuestionType } from '../../../shared/models/question' @Injectable({ providedIn: 'root' @@ -20,11 +17,30 @@ export class FinishTaskService { constructor( private schedule: ScheduleService, private kafka: KafkaService, - private config: ConfigService, - private appConfig: AppConfigService, - private logger: LogService + private config: ConfigService ) {} + processCompletedQuestionnaire(data, task, assessmentMetadata) { + // temporarily change both to healthkit + const type = task.name.toLowerCase().includes('health') + ? SchemaType.HEALTHKIT + : SchemaType.ASSESSMENT + return Promise.all([ + this.updateTaskToComplete(task), + !task.isDemo + ? this.kafka.prepareKafkaObjectAndStore(type, { + task, + data, + metadata: assessmentMetadata + }) + : [], + this.kafka + .prepareKafkaObjectAndStore(SchemaType.TIMEZONE, {}) + .then(() => this.kafka.sendAllFromCache()), + this.cancelNotificationsForCompletedTask(task) + ]) + } + updateTaskToComplete(task): Promise { return Promise.all([ this.schedule @@ -36,58 +52,6 @@ export class FinishTaskService { ]) } - processDataAndSend(answers, questions, timestamps, task) { - // NOTE: Do not send answers if demo questionnaire - if (task.isDemo) return Promise.resolve() - if (questions.some(question => question.field_type === 'health')) { - const results = this.processHealthQuestionnaireData( - answers, - timestamps, - questions - ) - Object.keys(results).forEach(key => - this.sendAnswersToKafka(results[key], task) - ) - } else { - return this.sendAnswersToKafka( - this.processQuestionnaireData(answers, timestamps, questions), - task - ) - } - } - - sendAnswersToKafka(processedAnswers, task) { - // If it's from health - if (processedAnswers instanceof Array) { - return Promise.all( - processedAnswers.map(p => { - return this.kafka.prepareKafkaObjectAndSend( - SchemaType.GENERAL_HEALTH, - { - task: task, - data: p - } - ) - }) - ).then(cacheValues => this.kafka.storeHealthDataInCache(cacheValues)) - } else { - return this.appConfig.getScheduleVersion().then(scheduleVersion => { - return Promise.all([ - this.kafka.prepareKafkaObjectAndSend(SchemaType.TIMEZONE, {}), - this.kafka.prepareKafkaObjectAndSend(SchemaType.ASSESSMENT, { - task: task, - data: Object.assign(processedAnswers, { scheduleVersion }) - }) - ]).then(([timezone, assessment]) => { - return Promise.all([ - this.kafka.storeInCache(timezone), - this.kafka.storeInCache(assessment) - ]).then(() => this.kafka.sendAllFromCache()) - }) - }) - } - } - createClinicalFollowUpTask(assessment): Promise { return this.schedule .generateSingleAssessmentTask( @@ -99,80 +63,6 @@ export class FinishTaskService { } // TODO process for general questionnaire schema - processHealthQuestionnaireData(answers, timestamps, questions) { - // this.logger.log('Answers to process', answers) - - let results = {} - for (let [key, value] of Object.entries(answers)) { - // value is array of datapoints - // key is name of data type - if (value.length) { - const type = this.getDataTypeFromKey(key) - const formatted = value.map(v => - Object.assign( - {}, - { - startTime: new Date(v.startDate).getTime(), - endTime: new Date(v.endDate).getTime(), - timeReceived: Date.now(), - sourceId: v.sourceBundleId, - sourceName: v.sourceName, - unit: v.unit, - key, - intValue: null, - floatValue: null, - doubleValue: null, - stringValue: null - }, - { [type]: v.value } - ) - ) - results[key] = formatted - } - } - return results - } - - getDataTypeFromKey(key) { - if ( - Object.values(HealthkitStringDataType).includes( - key as HealthkitStringDataType - ) - ) { - return HealthKitDataTypeKey.STRING - } else return HealthKitDataTypeKey.FLOAT - } - - processQuestionnaireData(answers, timestamps, questions) { - this.logger.log('Answers to process', answers) - const values = Object.entries(answers) - .filter(([k, v]) => timestamps[k]) - .map(([key, value]) => ({ - questionId: { string: key.toString() }, - value: { string: value.toString() }, - startTime: timestamps[key].startTime, - endTime: timestamps[key].endTime - })) - return { - answers: values, - scheduleVersion: '', - time: this.getTimeStart(questions, values), - timeCompleted: this.getTimeCompleted(values) - } - } - - getTimeStart(questions, answers) { - // NOTE: Do not include info screen as start time - const index = questions.findIndex(q => q.field_type !== QuestionType.info) - return index > -1 && answers[index] - ? answers[index].startTime - : answers[0].startTime - } - - getTimeCompleted(answers) { - return answers[answers.length - 1].endTime - } - cancelNotificationsForCompletedTask(task): Promise { console.log('Cancelling pending reminders for task..') const notifications = task.notifications ? task.notifications : [] diff --git a/src/app/pages/questions/services/healthkit.service.ts b/src/app/pages/questions/services/healthkit.service.ts new file mode 100644 index 000000000..e4cf3d19e --- /dev/null +++ b/src/app/pages/questions/services/healthkit.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@angular/core' +import { Health } from '@awesome-cordova-plugins/health/ngx' +import { Platform } from '@ionic/angular' +import { StorageService } from 'src/app/core/services/storage/storage.service' +import { StorageKeys } from 'src/app/shared/enums/storage' +import { + getMilliseconds, + setDateTimeToMidnight, + setDateTimeToMidnightEpoch +} from 'src/app/shared/utilities/time' + +import { LogService } from '../../../core/services/misc/log.service' + +declare var Media: any // stops errors w/ cordova-plugin-media-with-compression types + +@Injectable({ + providedIn: 'root' +}) +export class HealthkitService { + health_value: any + health_display: any + health_display_time: any + health_time: any + + notSupported = false + // The interval days for first query + DEFAULT_LOOKBACK_INTERVAL = 30 + MIN_POLL_TIMESTAMP = new Date( + new Date().getTime() - this.DEFAULT_LOOKBACK_INTERVAL * 24 * 60 * 60 * 1000 + ) + MAX_HOURLY_RECORD_LIMIT = 500 + + constructor( + private platform: Platform, + private logger: LogService, + private health: Health, + private storage: StorageService + ) { + this.initLastPollTimes() + } + + initLastPollTimes() { + return this.getLastPollTimes().then(dic => { + if (!dic) { + return this.setLastPollTimes({}) + } + }) + } + + getLastPollTimes() { + return this.storage.get(StorageKeys.HEALTH_LAST_POLL_TIMES) + } + + setLastPollTimes(value) { + this.storage.set(StorageKeys.HEALTH_LAST_POLL_TIMES, value) + } + + checkHealthkitSupported() { + return this.health.isAvailable() + } + + loadData(healthDataType) { + return this.getLastPollTimes().then(dic => { + let lastPollTime = this.MIN_POLL_TIMESTAMP + if (healthDataType in dic) lastPollTime = dic[healthDataType] + return this.health + .requestAuthorization([ + { + read: [healthDataType] //read only permission + } + ]) + .then(() => { + const endDate = new Date() + return this.query(lastPollTime, endDate, healthDataType).then(res => { + if (res.length) { + const lastDataDate = new Date(res[res.length - 1].endDate) + dic[healthDataType] = lastDataDate + this.setLastPollTimes(dic) + } + return res + }) + }) + .catch(e => { + console.log(e) + return null + }) + }) + } + + async query(queryStartTime: Date, queryEndTime: Date, dataType: string) { + let startTime = setDateTimeToMidnightEpoch(queryStartTime) + let endTime = startTime + getMilliseconds({ hours: 1 }) + let completeData = [] + while (endTime < queryEndTime.getTime()) { + await this.health + .query({ + startDate: new Date(startTime), + endDate: new Date(endTime), + dataType: dataType, + limit: this.MAX_HOURLY_RECORD_LIMIT + }) + .then(res => { + return (completeData = completeData.concat(res)) + }) + startTime = endTime + endTime = endTime + getMilliseconds({ hours: 1 }) + } + return completeData + } + + reset() { + return this.setLastPollTimes({}) + } +} diff --git a/src/app/pages/questions/services/questions.service.ts b/src/app/pages/questions/services/questions.service.ts index ac16ac4c4..9b5c3d4a2 100644 --- a/src/app/pages/questions/services/questions.service.ts +++ b/src/app/pages/questions/services/questions.service.ts @@ -96,10 +96,14 @@ export class QuestionsService { this.answerService.add(answer) } - getData() { + getData(questions) { + const answers = this.getAnswers() + const timestamps = this.timestampService.timestamps return { - answers: this.getAnswers(), - timestamps: this.timestampService.timestamps + answers, + timestamps, + time: this.getTimeStart(questions, answers, timestamps), + timeCompleted: this.getTimeCompleted(answers, timestamps) } } @@ -109,12 +113,29 @@ export class QuestionsService { getAnswers() { const answers = {} - this.answerService.keys.map( - d => (answers[d] = this.answerService.answers[d]) + const timestamps = this.timestampService.timestamps + this.answerService.keys.map(d => + timestamps[d] ? (answers[d] = this.answerService.answers[d]) : [] ) return answers } + getTimeStart(questions, answers, timestamps) { + // NOTE: Do not include info screen as start time + const index = questions.findIndex( + q => q.field_type !== QuestionType.info && answers[q.field_name] + ) + const firstKey = + index > -1 ? questions[index].field_name : questions[0].field_name + return timestamps[firstKey].startTime + } + + getTimeCompleted(answers, timestamps) { + const answerKeys = Object.keys(answers) + const lastKey = answerKeys[answerKeys.length - 1] + return timestamps[lastKey].endTime + } + getTime() { return getSeconds({ milliseconds: this.timestampService.getTimeStamp() }) } @@ -241,17 +262,16 @@ export class QuestionsService { } processCompletedQuestionnaire(task, questions): Promise { - const data = this.getData() - return Promise.all([ - this.finish.updateTaskToComplete(task), - this.finish.processDataAndSend( - data.answers, - questions, - data.timestamps, - task - ), - this.finish.cancelNotificationsForCompletedTask(task) - ]) + const type = task.type + return this.questionnaire + .getAssessmentForTask(type, task) + .then(assessment => + this.finish.processCompletedQuestionnaire( + this.getData(questions), + task, + assessment.questionnaire + ) + ) } handleClinicalFollowUp(assessment, completedInClinic?) { diff --git a/src/app/pages/settings/components/cache-send-modal/cache-send-modal.component.ts b/src/app/pages/settings/components/cache-send-modal/cache-send-modal.component.ts index dc95a2055..e99fb2cc7 100644 --- a/src/app/pages/settings/components/cache-send-modal/cache-send-modal.component.ts +++ b/src/app/pages/settings/components/cache-send-modal/cache-send-modal.component.ts @@ -18,8 +18,8 @@ export class CacheSendModalComponent { public modalCtrl: ModalController ) { this.result = this.params.get('data') - this.errors = this.result.filter(d => d instanceof Error) - this.successes = this.result.filter(d => !(d instanceof Error)) + this.errors = this.result['failedKeys'] + this.successes = this.result['successKeys'] } dismiss() { diff --git a/src/app/pages/settings/containers/settings-page.component.ts b/src/app/pages/settings/containers/settings-page.component.ts index f5d12eae2..d129a69d9 100755 --- a/src/app/pages/settings/containers/settings-page.component.ts +++ b/src/app/pages/settings/containers/settings-page.component.ts @@ -274,8 +274,7 @@ export class SettingsPageComponent { async sendCachedData() { const loader = await this.loadCtrl.create({ message: this.localization.translateKey(LocKeys.SETTINGS_WAIT_ALERT), - cssClass: 'custom-loading', - duration: 15000 + cssClass: 'custom-loading' }) loader.present() return this.settingsService.sendCachedData().then(async res => { diff --git a/src/app/shared/models/answer.ts b/src/app/shared/models/answer.ts index 29e17cca2..da74944b6 100755 --- a/src/app/shared/models/answer.ts +++ b/src/app/shared/models/answer.ts @@ -16,8 +16,8 @@ export interface Response { export interface AnswerValueExport { name: any version: any - answers: Response[] + answers: any[] time: number timeCompleted: number timeNotification: Object -} \ No newline at end of file +} diff --git a/src/app/shared/models/assessment.ts b/src/app/shared/models/assessment.ts index c9480cf28..cd4aeeeee 100755 --- a/src/app/shared/models/assessment.ts +++ b/src/app/shared/models/assessment.ts @@ -23,7 +23,7 @@ export interface Assessment { export interface QuestionnaireMetadata { repository?: string name: string - avsc: string + avsc?: string type?: string format?: string } diff --git a/src/app/shared/models/health.ts b/src/app/shared/models/health.ts index be3dbda6b..4729df4c5 100644 --- a/src/app/shared/models/health.ts +++ b/src/app/shared/models/health.ts @@ -3,7 +3,17 @@ export enum HealthkitFloatDataType { DISTANCE = 'distance', APPLE_EXERCISE_TIME = 'appleExerciseTime', VO2MAX = 'vo2Max', - ACTIVITY = 'activity' + ACTIVITY = 'activity', + CALORIES = 'calories' +} + +export enum HealthkitDataType { + STAIRS = 'stairs', + DISTANCE = 'distance', + APPLE_EXERCISE_TIME = 'appleExerciseTime', + VO2MAX = 'vo2Max', + ACTIVITY = 'activity', + CALORIES = 'calories' } export enum HealthkitStringDataType { @@ -28,7 +38,7 @@ export enum HealthkitSchemaType { } export interface HealthkitValueExport { - startTime: number + time: number endTime: number timeReceived: number sourceId: string diff --git a/src/app/shared/models/kafka.ts b/src/app/shared/models/kafka.ts index e145e311d..5708c0b22 100644 --- a/src/app/shared/models/kafka.ts +++ b/src/app/shared/models/kafka.ts @@ -4,19 +4,26 @@ export interface SchemaMetadata { schema: string } +export interface SchemaAndValue { + schema: any + value: any +} + export enum SchemaType { ASSESSMENT = 'assessment', COMPLETION_LOG = 'completion_log', TIMEZONE = 'timezone', APP_EVENT = 'app_event', OTHER = 'other', + KEY = 'key', + HEALTHKIT = 'healthkit', - // generic + // generic GENERAL_HEALTH = 'healthkit_generic_data', // aggregated data - // !Will have to remove activity here, since each activity acutally contains more payload - // Steps, Calroies, Nutrition [ 'steps', 'distance','calories','activity', 'nutrition'] + // !Will have to remove activity here, since each activity acutally contains more payload + // Steps, Calroies, Nutrition [ 'steps', 'distance','calories','activity', 'nutrition'] AGGREGATED_HEALTH = 'healthkit_aggregated_exercise_data' } @@ -27,6 +34,6 @@ export interface KeyExport { } export interface KafkaObject { - key: KeyExport + key?: KeyExport value: any } diff --git a/src/app/shared/models/question.ts b/src/app/shared/models/question.ts index dc24b7964..30f4227fd 100755 --- a/src/app/shared/models/question.ts +++ b/src/app/shared/models/question.ts @@ -95,3 +95,7 @@ export interface QuestionPosition { groupKeyIndex: number questionIndices: number[] } + +export enum WebInputType { + NHS = 'nhs' +} \ No newline at end of file diff --git a/src/app/shared/testing/mock-services.ts b/src/app/shared/testing/mock-services.ts index 30d85c201..ea8085940 100644 --- a/src/app/shared/testing/mock-services.ts +++ b/src/app/shared/testing/mock-services.ts @@ -25,6 +25,9 @@ export class RemoteConfigServiceMock { export class LogServiceMock { log() {} } + +export class ConverterFactoryServiceMock {} + export class ScheduleServiceMock {} export class NotificationServiceMock { init() {} @@ -52,3 +55,5 @@ export class WebIntentMock {} export class AppServerServiceMock {} export class MessageHandlerServiceMock {} export class GithubClientMock {} +export class CacheServiceMock {} +export class HealthkitServiceMock {} diff --git a/src/app/shared/utilities/form-validators.ts b/src/app/shared/utilities/form-validators.ts index a706743c5..a5c4f399c 100644 --- a/src/app/shared/utilities/form-validators.ts +++ b/src/app/shared/utilities/form-validators.ts @@ -5,3 +5,24 @@ export const URLRegEx = '(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})[/\\w .-]*/?' export function isValidURL(URL: string) { return !new FormControl(URL, Validators.pattern(URLRegEx)).errors } + +export function isValidNHSId(nhsId: string) { + const checksum = calculateNHSChecksum(nhsId) + const lastDigit: number = parseInt(nhsId[9], 10) + return checksum === lastDigit +} + +export function calculateNHSChecksum(nhsId: string): number { + nhsId = nhsId.replace(/\s/g, '') // Remove any spaces + if (nhsId.length !== 10) { + throw new Error('Invalid NHS ID length. Expected length is 10.') + } + const weights: number[] = [10, 9, 8, 7, 6, 5, 4, 3, 2] // Weights for each digit + const total: number = nhsId + .split('') + .map((digit, index) => parseInt(digit, 10) * weights[index]) + .slice(0, 9) + .reduce((acc, curr) => acc + curr, 0) + const checksum: number = (11 - (total % 11)) % 11 + return checksum +} diff --git a/src/assets/data/defaultConfig.ts b/src/assets/data/defaultConfig.ts index db9f16f97..143d8864a 100755 --- a/src/assets/data/defaultConfig.ts +++ b/src/assets/data/defaultConfig.ts @@ -209,6 +209,9 @@ export const DefaultRequestJSONContentType = 'application/json' export const DefaultKafkaRequestContentType = 'application/vnd.kafka.avro.v2+json' +// *Default HTTP request content encoding +export const DefaultCompressedContentEncoding = 'gzip' + // *Default HTTP request client accept type export const DefaultClientAcceptType = 'application/vnd.kafka.v2+json, application/vnd.kafka+json; q=0.9, application/json; q=0.8'