From 1630bae026fd1a8abe28236963fe5991793eefb6 Mon Sep 17 00:00:00 2001 From: esurface Date: Thu, 16 May 2024 12:09:39 -0400 Subject: [PATCH 01/42] Add components for auth in online-survey-app --- online-survey-app/package.json | 5 +- .../src/app/app-routing.module.ts | 9 +- online-survey-app/src/app/app.module.ts | 11 +- .../edit-user/edit-user.component.css | 9 + .../edit-user/edit-user.component.html | 59 +++++ .../edit-user/edit-user.component.spec.ts | 25 ++ .../edit-user/edit-user.component.ts | 78 ++++++ .../_components/login/login.component.css | 33 +++ .../_components/login/login.component.html | 31 +++ .../_components/login/login.component.spec.ts | 25 ++ .../auth/_components/login/login.component.ts | 58 +++++ .../update-personal-profile.component.css | 4 + .../update-personal-profile.component.html | 68 ++++++ .../update-personal-profile.component.spec.ts | 25 ++ .../update-personal-profile.component.ts | 87 +++++++ .../update-user-role.component.css | 16 ++ .../update-user-role.component.html | 16 ++ .../update-user-role.component.spec.ts | 25 ++ .../update-user-role.component.ts | 76 ++++++ .../user-registration.component.css | 3 + .../user-registration.component.html | 70 ++++++ .../user-registration.component.spec.ts | 25 ++ .../user-registration.component.ts | 81 ++++++ .../user-registration/user.model.interface.ts | 8 + .../has-a-permission.directive.spec.ts | 8 + .../_directives/has-a-permission.directive.ts | 31 +++ .../has-all-permissions.directive.spec.ts | 8 + .../has-all-permissions.directive.ts | 29 +++ .../has-some-permissions.directive.spec.ts | 8 + .../has-some-permissions.directive.ts | 31 +++ .../core/auth/_guards/login-guard.service.ts | 19 ++ .../_services/authentication.service.spec.ts | 15 ++ .../auth/_services/authentication.service.ts | 230 ++++++++++++++++++ .../core/auth/_services/user.service.spec.ts | 15 ++ .../app/core/auth/_services/user.service.ts | 154 ++++++++++++ .../src/app/core/auth/auth-routing.module.ts | 20 ++ .../src/app/core/auth/auth.module.ts | 43 ++++ .../src/app/shared/_factories/db.factory.ts | 94 +++++++ .../shared/_services/forms-service.service.ts | 8 +- .../src/app/shared/_services/menu.service.ts | 20 ++ .../_services/tangy-error-handler.service.ts | 12 + .../shared/_services/translation-marker.ts | 3 + .../app/shared/classes/app-config.class.ts | 197 +++++++++++++++ .../app/shared/classes/user-account.class.ts | 12 + .../app/shared/classes/user-database.class.ts | 148 +++++++++++ .../app/shared/classes/user-signup.class.ts | 10 + .../src/app/shared/shared.module.ts | 30 +++ 47 files changed, 1980 insertions(+), 12 deletions(-) create mode 100644 online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.ts create mode 100644 online-survey-app/src/app/core/auth/_components/login/login.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/login/login.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/login/login.component.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_components/login/login.component.ts create mode 100644 online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.ts create mode 100644 online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.ts create mode 100644 online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.ts create mode 100644 online-survey-app/src/app/core/auth/_components/user-registration/user.model.interface.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.ts create mode 100644 online-survey-app/src/app/core/auth/_guards/login-guard.service.ts create mode 100644 online-survey-app/src/app/core/auth/_services/authentication.service.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_services/authentication.service.ts create mode 100644 online-survey-app/src/app/core/auth/_services/user.service.spec.ts create mode 100644 online-survey-app/src/app/core/auth/_services/user.service.ts create mode 100644 online-survey-app/src/app/core/auth/auth-routing.module.ts create mode 100644 online-survey-app/src/app/core/auth/auth.module.ts create mode 100644 online-survey-app/src/app/shared/_factories/db.factory.ts create mode 100644 online-survey-app/src/app/shared/_services/menu.service.ts create mode 100644 online-survey-app/src/app/shared/_services/tangy-error-handler.service.ts create mode 100644 online-survey-app/src/app/shared/_services/translation-marker.ts create mode 100644 online-survey-app/src/app/shared/classes/app-config.class.ts create mode 100644 online-survey-app/src/app/shared/classes/user-account.class.ts create mode 100644 online-survey-app/src/app/shared/classes/user-database.class.ts create mode 100644 online-survey-app/src/app/shared/classes/user-signup.class.ts create mode 100644 online-survey-app/src/app/shared/shared.module.ts diff --git a/online-survey-app/package.json b/online-survey-app/package.json index 3792ce7c22..feb1c86b68 100644 --- a/online-survey-app/package.json +++ b/online-survey-app/package.json @@ -21,7 +21,10 @@ "@angular/platform-browser": "~10.1.0", "@angular/platform-browser-dynamic": "~10.1.0", "@angular/router": "~10.1.0", + "@ngx-translate/core": "^11.0.1", + "@ngx-translate/http-loader": "^4.0.0", "@webcomponents/webcomponentsjs": "^2.4.4", + "jwt-decode": "^4.0.0", "material-design-icons-iconfont": "^6.1.0", "redux": "^4.0.5", "rxjs": "~6.6.0", @@ -34,9 +37,9 @@ "@angular-devkit/build-angular": "~0.1001.0", "@angular/cli": "~10.1.0", "@angular/compiler-cli": "~10.1.0", - "@types/node": "^12.11.1", "@types/jasmine": "~3.5.0", "@types/jasminewd2": "~2.0.3", + "@types/node": "^12.11.1", "codelyzer": "^6.0.0", "jasmine-core": "~3.6.0", "jasmine-spec-reporter": "~5.0.0", diff --git a/online-survey-app/src/app/app-routing.module.ts b/online-survey-app/src/app/app-routing.module.ts index 1870aea915..840288ee4b 100644 --- a/online-survey-app/src/app/app-routing.module.ts +++ b/online-survey-app/src/app/app-routing.module.ts @@ -3,12 +3,13 @@ import { Routes, RouterModule } from '@angular/router'; import { FormSubmittedSuccessComponent } from './form-submitted-success/form-submitted-success.component'; import { FormsListComponent } from './forms-list/forms-list.component'; import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; +import { LoginGuard } from './core/auth/_guards/login-guard.service'; const routes: Routes = [ - //{ path: '', component: FormsListComponent }, - { path: 'form-submitted-success', component: FormSubmittedSuccessComponent }, - { path: 'form/:id', component: TangyFormsPlayerComponent }, - { path: 'form/option/:formId/:option', component: TangyFormsPlayerComponent } + { path: 'forms-list', component: FormsListComponent, canActivate: [LoginGuard] }, + { path: 'form-submitted-success', component: FormSubmittedSuccessComponent, canActivate: [LoginGuard] }, + { path: 'form/:id', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, + { path: 'form/option/:formId/:option', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, ]; @NgModule({ diff --git a/online-survey-app/src/app/app.module.ts b/online-survey-app/src/app/app.module.ts index 54277c80a5..8080a7af5b 100644 --- a/online-survey-app/src/app/app.module.ts +++ b/online-survey-app/src/app/app.module.ts @@ -3,6 +3,7 @@ import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; +import { AuthModule } from './core/auth/auth.module'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {MatToolbarModule} from '@angular/material/toolbar'; import {MatCardModule} from '@angular/material/card'; @@ -10,11 +11,13 @@ import {MatTabsModule} from '@angular/material/tabs'; import {MatMenuModule} from '@angular/material/menu'; import {MatListModule} from '@angular/material/list'; import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { TangySvgLogoComponent } from './shared/tangy-svg-logo/tangy-svg-logo.component'; import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; import { HttpClientModule } from '@angular/common/http'; import { FormsListComponent } from './forms-list/forms-list.component'; import { FormSubmittedSuccessComponent } from './form-submitted-success/form-submitted-success.component'; +import { TangyErrorHandler } from './shared/_services/tangy-error-handler.service'; @NgModule({ declarations: [ @@ -25,8 +28,9 @@ import { FormSubmittedSuccessComponent } from './form-submitted-success/form-sub FormSubmittedSuccessComponent ], imports: [ - BrowserModule, + AuthModule, AppRoutingModule, + BrowserModule, BrowserAnimationsModule, MatToolbarModule, MatMenuModule, @@ -34,10 +38,11 @@ import { FormSubmittedSuccessComponent } from './form-submitted-success/form-sub HttpClientModule, MatCardModule, MatTabsModule, - MatListModule + MatListModule, + MatSnackBarModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], - providers: [], + providers: [TangyErrorHandler], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.css b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.css new file mode 100644 index 0000000000..7e475170aa --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.css @@ -0,0 +1,9 @@ +form { + margin: 20px; +} + +:host() { + + display: block; + padding-right: 45px; +} \ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.html b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.html new file mode 100644 index 0000000000..99bdd90ae9 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.html @@ -0,0 +1,59 @@ +
+

+ {{'Edit User'|translate}} {{user.username}} +

+
+ + + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'Please enter a valid email address'|translate}} + + +
+
+ Update User password? +
+
+
+ + + +
+
+ + + + {{'Passwords do not match'|translate}} + + +
+ + {{statusMessage.message}} + +

+ +

+
+
\ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.spec.ts b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.spec.ts new file mode 100644 index 0000000000..3702bd7a39 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditUserComponent } from './edit-user.component'; + +describe('EditUserComponent', () => { + let component: EditUserComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ EditUserComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.ts b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.ts new file mode 100644 index 0000000000..d261999573 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/edit-user/edit-user.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit } from '@angular/core'; +import { User } from '../user-registration/user.model.interface'; +import { UserService } from '../../_services/user.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import {HttpClient} from "@angular/common/http"; + +@Component({ + selector: 'app-edit-user', + templateUrl: './edit-user.component.html', + styleUrls: ['./edit-user.component.css'] +}) +export class EditUserComponent implements OnInit { + user: User; + updateUserPassword = false; + statusMessage = { type: '', message: '' }; + disableSubmit = false; + passwordIsNotStrong = { type: 'error', message: _TRANSLATE('Password is not strong enough.') }; + incorrectAdminPassword = { type: 'error', message: _TRANSLATE('Incorrect Admin Password') }; + passwordPolicy: string + passwordRecipe: string + constructor( + private userService: UserService, + private route: ActivatedRoute, + private errorHandler: TangyErrorHandler, + private router: Router, + private httpClient: HttpClient + ) { } + + async ngOnInit() { + this.user = await this.userService.getAUserByUsername(this.route.snapshot.paramMap.get('username')) as User; + this.user.password = ''; + this.user.confirmPassword = ''; + try { + const result: any = await this.httpClient.get('/configuration/passwordPolicyConfig').toPromise(); + if(result.T_PASSWORD_POLICY){ + this.passwordPolicy = result.T_PASSWORD_POLICY; + } + if(result.T_PASSWORD_RECIPE){ + this.passwordRecipe = result.T_PASSWORD_RECIPE; + } + } catch (error) { + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + this.passwordIsNotStrong.message = this.passwordIsNotStrong.message + ' ' + this.passwordRecipe + } + + async editUser() { + try { + if (!this.updateUserPassword) { + this.user.password = null; + this.user.confirmPassword = null; + } + const policy = new RegExp(this.passwordPolicy) + if (!policy.test(this.user.password)) { + this.statusMessage = this.passwordIsNotStrong + // this.disableSubmit = true + // this.errorHandler.handleError(this.passwordIsNotStrong.message); + return + } else { + // Clear out statusMessage is it had been set earlier. + this.statusMessage = { type: '', message: '' }; + } + const data = await this.userService.updateUserDetails({...this.user, updateUserPassword: this.updateUserPassword}); + if (data === 200) { + this.errorHandler.handleError(_TRANSLATE('User Details Updated Successfully')); + this.router.navigate(['users']) + } else { + this.errorHandler.handleError(_TRANSLATE('User Details could not be Updated')); + } + } catch (error) { + this.errorHandler.handleError(_TRANSLATE('User Details could not be Updated')); + } + } +} diff --git a/online-survey-app/src/app/core/auth/_components/login/login.component.css b/online-survey-app/src/app/core/auth/_components/login/login.component.css new file mode 100644 index 0000000000..e347211a77 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/login/login.component.css @@ -0,0 +1,33 @@ +mat-card { + width: 300px; + margin: 30px auto; +} +.mat-title { + color: var(--primary-color); + font-size: 1.5em; + font-weight: 400; + font-family: Roboto, 'Helvetica Nue', sans-serif; +} +#logo { + margin-bottom: 15px; +} +mat-placeholder i { + margin-right: 0.075em; +} + +input.mat-input-element { + margin-top: 1em; +} + +.mat-placeholder-required.mat-form-field-required-marker.ng-tns-c6-2 + .mat-form-field-invalid + .mat-form-field-placeholder.mat-accent, +.mat-form-field-invalid + .mat-form-field-placeholder + .mat-form-field-required-marker { + position: relative; + bottom: 1em !important; +} +.mat-form-field { + width: 100%; +} diff --git a/online-survey-app/src/app/core/auth/_components/login/login.component.html b/online-survey-app/src/app/core/auth/_components/login/login.component.html new file mode 100644 index 0000000000..839e51e028 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/login/login.component.html @@ -0,0 +1,31 @@ + + Loading... + + +
+ + + + + face + {{'Username'|translate}} + + +
+ + + + lock_open + {{'Password'|translate}} + + +
+ + + {{'Register'|translate}} + + {{errorMessage}} + +
+
+ \ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/login/login.component.spec.ts b/online-survey-app/src/app/core/auth/_components/login/login.component.spec.ts new file mode 100644 index 0000000000..d6d85a8465 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/login/login.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LoginComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_components/login/login.component.ts b/online-survey-app/src/app/core/auth/_components/login/login.component.ts new file mode 100644 index 0000000000..4ef807c3f9 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/login/login.component.ts @@ -0,0 +1,58 @@ +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthenticationService } from '../../_services/authentication.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; + + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.css'] +}) +export class LoginComponent implements OnInit { + + errorMessage = ''; + returnUrl: string; // stores the value of the url to redirect to after login + user = { username: '', password: '' }; + @ViewChild('customLoginMarkup', {static: true}) customLoginMarkup: ElementRef; + ready = false + + constructor( + private authenticationService: AuthenticationService, + private route: ActivatedRoute, + private router: Router, + ) { } + + async ngOnInit() { + this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || 'forms-list'; + + if (await this.authenticationService.isLoggedIn()) { + this.router.navigate([this.returnUrl]); + } + // We always want to log in from the "front door". If we log in from path of `/app/`, then the global cookie + // will be tied to that pathname and not available at other pathnames such as `/csv/` that is looking for authentication. + if (window.location.pathname !== '/') { + window.location.pathname = '/' + } + this.customLoginMarkup.nativeElement.innerHTML = await this.authenticationService.getCustomLoginMarkup() + this.ready = true + } + async loginUser() { + try { + + if (window.location.origin.startsWith('http://localhost')) { + // If we are running on localhost, we want to use the local server for authentication + this.router.navigate([this.returnUrl]); + + } else if (await this.authenticationService.login(this.user.username, this.user.password)) { + this.router.navigate([this.returnUrl]); + } else { + this.errorMessage = _TRANSLATE('Login Unsuccesful'); + } + } catch (error) { + this.errorMessage = _TRANSLATE('Login Unsuccesful'); + console.error(error); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.css b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.css new file mode 100644 index 0000000000..09e9b04a15 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.css @@ -0,0 +1,4 @@ +#container { + margin: 0px 15px; + +} \ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.html b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.html new file mode 100644 index 0000000000..dea5885145 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.html @@ -0,0 +1,68 @@ +

+ {{'Edit User'|translate }} {{user.username}} +

+ +

+ {{'Edit User'|translate }} +

+
+
+
+ + + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'Please enter a valid email address'|translate}} + + +
+
+ Update User password? +
+
+
+ + + + + + +
+
+ + + + {{'Passwords do not match'|translate}} + + +
+ + {{statusMessage.message}} + +

+ +

+
+
\ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.spec.ts b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.spec.ts new file mode 100644 index 0000000000..e3bb688e39 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UpdatePersonalProfileComponent } from './update-personal-profile.component'; + +describe('UpdatePersonalProfileComponent', () => { + let component: UpdatePersonalProfileComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UpdatePersonalProfileComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UpdatePersonalProfileComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.ts b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.ts new file mode 100644 index 0000000000..12dee61574 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-personal-profile/update-personal-profile.component.ts @@ -0,0 +1,87 @@ +import { Router } from '@angular/router'; +import { Component, OnInit } from '@angular/core'; +import { MenuService } from 'src/app/shared/_services/menu.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import { UserService } from '../../_services/user.service'; +import { User } from '../user-registration/user.model.interface'; +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; +import {HttpClient} from "@angular/common/http"; + +@Component({ + selector: 'app-update-personal-profile', + templateUrl: './update-personal-profile.component.html', + styleUrls: ['./update-personal-profile.component.css'] +}) +export class UpdatePersonalProfileComponent implements OnInit { + + user; + updateUserPassword = false; + statusMessage = { type: '', message: '' }; + disableSubmit = false; + passwordIsNotStrong = { type: 'error', message: _TRANSLATE('Password is not strong enough.') }; + incorrectAdminPassword = { type: 'error', message: _TRANSLATE('Incorrect Admin Password') }; + passwordPolicy: string + passwordRecipe: string + config:any = { enabledModules: [] } + + constructor( + private menuService: MenuService, + private userService: UserService, + private errorHandler: TangyErrorHandler, + private router: Router, + private httpClient: HttpClient + ) { } + + async ngOnInit() { + this.menuService.setContext(_TRANSLATE('Update My Profile')); + this.user = await this.userService.getMyUser(); + this.user.password = ''; + this.user.confirmPassword = ''; + try { + const result: any = await this.httpClient.get('/configuration/passwordPolicyConfig').toPromise(); + if(result.T_PASSWORD_POLICY){ + this.passwordPolicy = result.T_PASSWORD_POLICY; + } + if(result.T_PASSWORD_RECIPE){ + this.passwordRecipe = result.T_PASSWORD_RECIPE; + } + } catch (error) { + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + this.passwordIsNotStrong.message = this.passwordIsNotStrong.message + ' ' + this.passwordRecipe + } + async editUser() { + this.statusMessage = { type: '', message: '' }; + try { + if (!this.updateUserPassword) { + this.user.password = null; + this.user.confirmPassword = null; + this.user.currentPassword = null; + } + + // const policy = new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%\^&\*])(?=.{8,})'); + const policy = new RegExp(this.passwordPolicy) + if (!policy.test(this.user.password)) { + this.statusMessage = this.passwordIsNotStrong + // this.disableSubmit = true + // this.errorHandler.handleError(this.passwordIsNotStrong.message); + return + } else { + // Clear out statusMessage is it had been set earlier. + this.statusMessage = { type: '', message: '' }; + } + + const data = await this.userService.updateMyUser({...this.user, updateUserPassword: this.updateUserPassword}); + if (data === 200) { + this.errorHandler.handleError(_TRANSLATE('User Details Updated Successfully')); + this.router.navigate(['/']) + } else { + this.errorHandler.handleError(_TRANSLATE('User Details could not be Updated')); + } + } catch (error) { + this.errorHandler.handleError(_TRANSLATE('User Details could not be Updated')); + } + } +} diff --git a/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.css b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.css new file mode 100644 index 0000000000..03834b16a8 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.css @@ -0,0 +1,16 @@ +.tangy-input-width{ + width: 80%; +} + +p { + margin: 15px; +} + +mat-checkbox{ + display: block; + margin: 0 10px; +} + +button { + margin-left: 15px; +} diff --git a/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.html b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.html new file mode 100644 index 0000000000..81450a32a7 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.html @@ -0,0 +1,16 @@ + +
+
+

{{'Update Role of' | translate}}: {{username}}

+ +
+

+ + {{role.role}} + +

+

{{'No Roles exist yet. '|translate}}

+ +
\ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.spec.ts b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.spec.ts new file mode 100644 index 0000000000..1db6f21c83 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UpdateUserRoleComponent } from './update-user-role.component'; + +describe('UpdateUserRoleComponent', () => { + let component: UpdateUserRoleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UpdateUserRoleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UpdateUserRoleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.ts b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.ts new file mode 100644 index 0000000000..497de34091 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/update-user-role/update-user-role.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit, AfterViewInit, ElementRef, ViewChild } from '@angular/core'; +import { Breadcrumb } from 'src/app/shared/_components/breadcrumb/breadcrumb.component'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import { ActivatedRoute, Router } from '@angular/router'; +import { GroupsService } from 'src/app/groups/services/groups.service'; +import { UserService } from '../../_services/user.service'; +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; +import { AuthenticationService } from '../../_services/authentication.service'; + +@Component({ + selector: 'app-update-user-role', + templateUrl: './update-user-role.component.html', + styleUrls: ['./update-user-role.component.css'] +}) +export class UpdateUserRoleComponent implements OnInit { + + user; + username; + role; + groupId; + title = _TRANSLATE('Update User\'s roles'); + breadcrumbs: Array = []; + allRoles; + myGroup; + @ViewChild('search', { static: false }) search: ElementRef; + constructor( + private route: ActivatedRoute, + private groupsService: GroupsService, + private usersService: UserService, + private errorHandler: TangyErrorHandler, + private router: Router, + private authenticationService: AuthenticationService + ) { } + + async ngOnInit() { + this.breadcrumbs = [ + { + label: _TRANSLATE('Security'), + url: `security` + }, + { + label: _TRANSLATE(`Update User's Roles`), + url: `security/assign-role` + } + ]; + this.username = this.route.snapshot.paramMap.get('username'); + this.groupId = this.route.snapshot.paramMap.get('groupId'); + this.allRoles = await this.authenticationService.getAllRoles(this.groupId); + this.user = await this.usersService.getAUserByUsername(this.username); + this.myGroup = this.user.groups.find(g => g.groupName === this.groupId); + this.myGroup.roles = this.myGroup?.roles ?? []; + this.role = { groupName: this.groupId, roles: this.myGroup.roles }; + } + + async addUserToGroup() { + try { + await this.groupsService.addUserToGroup(this.groupId, this.username, this.role); + this.errorHandler.handleError(_TRANSLATE('User Added to Group Successfully')); + this.router.navigate([`groups/${this.groupId}`]); + } catch (error) { + console.log(error); + } + } + + doesUserHaveRole(role) { + return this.myGroup.roles.indexOf(role) >= 0; + } + onSelectChange(role, value) { + if (value) { + this.role.roles = [...new Set([...this.role.roles, role])]; + } else { + this.role.roles = this.role.roles.filter(perm => perm !== role); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.css b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.css new file mode 100644 index 0000000000..6c157bdf31 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.css @@ -0,0 +1,3 @@ +.tangy-full-width { + width: 98%; +} diff --git a/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.html b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.html new file mode 100644 index 0000000000..f80e1a10f4 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.html @@ -0,0 +1,70 @@ +
+ + + {{'Add New User'|translate}} + + +
+ + +
+
+ + {{'Username Unavailable'|translate}} + {{'Username Available'|translate}} + + {{'This Field is Required'|translate}} + + +
+
+
+ + + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'This Field is Required'|translate}} + + +
+
+ + + + {{'Please enter a valid email address'|translate}} + + +
+
+ + + +
+
+ + + + {{'Passwords do not match'|translate}} + + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.spec.ts b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.spec.ts new file mode 100644 index 0000000000..39b3cb325f --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserRegistrationComponent } from './user-registration.component'; + +describe('UserRegistrationComponent', () => { + let component: UserRegistrationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UserRegistrationComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserRegistrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.ts b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.ts new file mode 100644 index 0000000000..48bd0d4359 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/user-registration/user-registration.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit, AfterContentInit } from '@angular/core'; +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import { UserService } from '../../_services/user.service'; +import { User } from './user.model.interface'; +import {ActivatedRoute} from '@angular/router'; + + +@Component({ + selector: 'app-user-registration', + templateUrl: './user-registration.component.html', + styleUrls: ['./user-registration.component.css'] +}) +export class UserRegistrationComponent implements OnInit, AfterContentInit { + + user: User = { + username: '', + password: '', + email: '', + firstName: '', + lastName: '' + }; + userExists: boolean = null; + userNameUnavailableMessage = { type: 'error', message: _TRANSLATE('Username Unavailable') }; + userNameAvailableMessage = { type: 'success', message: _TRANSLATE('Username Available') }; + couldNotCreateUserMessage = { type: 'error', message: _TRANSLATE('Could Not Create User') }; + id: string; + + constructor(private userService: UserService, private errorHandler: TangyErrorHandler, private route: ActivatedRoute) { } + + ngOnInit() { + } + + async ngAfterContentInit() { + this.route.params.subscribe(async params => { + this.id = params.id; + // await this.userService.doesUserExist(this.id); + }) + } + + async createUser() { + try { + delete this.user.confirmPassword; + const userData = Object.assign({}, this.user); + if (!(await this.doesUserExist(this.user.username))) { + const result: any = await this.userService.createUser(userData); + if (result && result.statusCode && result.statusCode === 200) { + this.errorHandler.handleError(_TRANSLATE('User Created Succesfully')); + this.user = { + username: '', + password: '', + email: '', + firstName: '', + lastName: '' + }; + } else { + this.errorHandler.handleError(_TRANSLATE('Could Not Create User')); + } + return result; + } + } catch (error) { + this.errorHandler.handleError(_TRANSLATE('Could Not Create User')); + } + } + + async doesUserExist(username: string) { + try { + this.user.username = username.replace(/\s/g, ''); // Remove all whitespaces including spaces and tabs + if (this.user.username.length > 0) { + this.userExists = await this.userService.doesUserExist(username); + return this.userExists; + } else { + this.userExists = null; + return this.userExists; + } + } catch (error) { + console.error(error); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_components/user-registration/user.model.interface.ts b/online-survey-app/src/app/core/auth/_components/user-registration/user.model.interface.ts new file mode 100644 index 0000000000..3346f356e9 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/user-registration/user.model.interface.ts @@ -0,0 +1,8 @@ +export interface User { + username: string; + password: string; + email: string; + firstName:string; + lastName:string; + confirmPassword?: string; +} diff --git a/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.spec.ts b/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.spec.ts new file mode 100644 index 0000000000..403650b0ba --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.spec.ts @@ -0,0 +1,8 @@ +import { HasAPermissionDirective } from './has-a-permission.directive'; + +describe('HasAPermissionDirective', () => { + it('should create an instance', () => { + const directive = new HasAPermissionDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.ts b/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.ts new file mode 100644 index 0000000000..1204beeb01 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-a-permission.directive.ts @@ -0,0 +1,31 @@ +import { Directive, Input, TemplateRef, ViewContainerRef, OnInit } from '@angular/core'; +import { AuthenticationService } from '../_services/authentication.service'; + +@Directive({ + selector: '[appHasAPermission]' +}) +export class HasAPermissionDirective implements OnInit { + private _groupId; + private _permission; + @Input() + set appHasAPermissionGroup(value: string) { + this._groupId = value; + } + @Input() + set appHasAPermissionPermission(value: string[]) { + this._permission = value; + } + + constructor(private authenticationService: AuthenticationService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef) { } + async ngOnInit() { + const isPermitted = await this.authenticationService.doesUserHaveAPermission(this._groupId, this._permission); + if (isPermitted) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.spec.ts b/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.spec.ts new file mode 100644 index 0000000000..00447ee7b3 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.spec.ts @@ -0,0 +1,8 @@ +import { HasAllPermissionsDirective } from './has-all-permissions.directive'; + +describe('HasAllPermissionsDirective', () => { + it('should create an instance', () => { + const directive = new HasAllPermissionsDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.ts b/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.ts new file mode 100644 index 0000000000..4f19f18b07 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-all-permissions.directive.ts @@ -0,0 +1,29 @@ +import { Directive, Input, AfterViewInit, TemplateRef, ViewContainerRef, OnInit } from '@angular/core'; +import { AuthenticationService } from '../_services/authentication.service'; +@Directive({ + selector: '[appHasAllPermissions]' +}) +export class HasAllPermissionsDirective implements OnInit { + private _groupId; + private _permissions; + @Input() + set appHasAllPermissionsGroup(value: string) { + this._groupId = value; + } + @Input() + set appHasAllPermissionsPermissions(value: string[]) { + this._permissions = value; + } + + constructor(private authenticationService: AuthenticationService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef) { } + async ngOnInit() { + const isPermitted = await this.authenticationService.doesUserHaveAllPermissions(this._groupId, this._permissions); + if (isPermitted) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + } +} diff --git a/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.spec.ts b/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.spec.ts new file mode 100644 index 0000000000..83187b8309 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.spec.ts @@ -0,0 +1,8 @@ +import { HasSomePermissionsDirective } from './has-some-permissions.directive'; + +describe('HasSomePermissionsDirective', () => { + it('should create an instance', () => { + const directive = new HasSomePermissionsDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.ts b/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.ts new file mode 100644 index 0000000000..e1dcdbcd77 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_directives/has-some-permissions.directive.ts @@ -0,0 +1,31 @@ +import { Directive, Input, TemplateRef, ViewContainerRef, OnInit } from '@angular/core'; +import { AuthenticationService } from '../_services/authentication.service'; + +@Directive({ + selector: '[appHasSomePermissions]' +}) +export class HasSomePermissionsDirective implements OnInit { + private _groupId; + private _permissions; + @Input() + set appHasSomePermissionsGroup(value: string) { + this._groupId = value; + } + @Input() + set appHasSomePermissionsPermissions(value: string[]) { + this._permissions = value; + } + + constructor(private authenticationService: AuthenticationService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef) { } + async ngOnInit() { + const isPermitted = await this.authenticationService.doesUserHaveSomePermissions(this._groupId, this._permissions); + if (isPermitted) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts new file mode 100644 index 0000000000..1b48299f84 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { AuthenticationService } from '../_services/authentication.service'; + +@Injectable() +export class LoginGuard implements CanActivate { + + constructor( + private router: Router, + private authenticationService: AuthenticationService + ) { } + async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + if (await this.authenticationService.isLoggedIn()) { + return true; + } + this.router.navigate([route, 'login'], { queryParams: { returnUrl: state.url } }); + return false; + } +} diff --git a/online-survey-app/src/app/core/auth/_services/authentication.service.spec.ts b/online-survey-app/src/app/core/auth/_services/authentication.service.spec.ts new file mode 100644 index 0000000000..0d04c8a68a --- /dev/null +++ b/online-survey-app/src/app/core/auth/_services/authentication.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { AuthenticationService } from './authentication.service'; + +describe('AuthenticationService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [AuthenticationService] + }); + }); + + it('should be created', inject([AuthenticationService], (service: AuthenticationService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/online-survey-app/src/app/core/auth/_services/authentication.service.ts b/online-survey-app/src/app/core/auth/_services/authentication.service.ts new file mode 100644 index 0000000000..90395ebd19 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_services/authentication.service.ts @@ -0,0 +1,230 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { UserService } from './user.service'; +import { Subject } from 'rxjs'; +import { jwtDecode } from 'jwt-decode'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; + +@Injectable() +export class AuthenticationService { + public currentUserLoggedIn$: any; + private _currentUserLoggedIn: boolean; + constructor(private userService: UserService, private http: HttpClient, private errorHandler: TangyErrorHandler) { + this.currentUserLoggedIn$ = new Subject(); + } + async login(username: string, password: string) { + try { + const data = await this.http.post('/login', {username, password}, {observe: 'response'}).toPromise(); + if (data.status === 200) { + const token = data.body['data']['token']; + await this.setTokens(token); + return true; + } else { + return false; + } + } catch (error) { + console.error(error); + localStorage.removeItem('token'); + localStorage.removeItem('user_id'); + localStorage.removeItem('permissions'); + return false; + } + } + async isLoggedIn():Promise { + + if (window.location.origin.startsWith('http://localhost')) { + localStorage.setItem('user_id', 'testuser'); + } + + this._currentUserLoggedIn = false; + this._currentUserLoggedIn = !!localStorage.getItem('user_id'); + this.currentUserLoggedIn$.next(this._currentUserLoggedIn); + return this._currentUserLoggedIn; + } + + async validateSession():Promise { + const status = await this.http.get(`/login/validate/${localStorage.getItem('user_id')}`).toPromise() + return status['valid'] + } + + + async logout() { + await localStorage.removeItem('token'); + await localStorage.removeItem('user_id'); + await localStorage.removeItem('password'); + localStorage.removeItem('permissions'); + document.cookie = "Authorization=;max-age=-1"; + this._currentUserLoggedIn = false; + this.currentUserLoggedIn$.next(this._currentUserLoggedIn); + } + + async extendUserSession() { + const username = localStorage.getItem('user_id'); + try { + const data = await this.http.post('/extendSession', {username}, {observe: 'response'}).toPromise(); + if (data.status === 200) { + const token = data.body['data']['token']; + await this.setTokens(token); + return true; + } else { + return false; + } + } catch (error) { + console.log(error); + } + } + + async setTokens(token) { + const jwtData = jwtDecode(token); + document.cookie = "Authorization=;max-age=-1"; + localStorage.setItem('token', token); + localStorage.setItem('user_id', jwtData['username']); + localStorage.setItem('permissions', JSON.stringify(jwtData['permissions'])); + document.cookie = `Authorization=${token}`; + + const user = await this.userService.getMyUser(); + window['userProfile'] = user; + window['userId'] = user._id; + window['username'] = jwtData['username']; + } + async getPermissionsList() { + try { + const data = await this.http.get('/permissionsList', {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body; + } + } catch (error) { + console.error(error); + return {groupPermissions: [], sitewidePermissions: []} + } + } + async getSitewidePermissionsByUsername(username) { + try { + const data = await this.http.get(`/sitewidePermissionsByUsername/${username}`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body; + } + } catch (error) { + console.error(error); + return {groupPermissions:[], sitewidePermissions:[]} + } + } + + async getUserGroupPermissionsByGroupName(groupName) { + try { + const data = await this.http.get(`/users/groupPermissionsByGroupName/${groupName}`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + const token = data.body['data']['token']; + await this.setTokens(token); + return true; + } + } catch (error) { + console.error(error); + return false; + } + } + + setUserGroupPermissionsByGroupName(groupName) { + + } + // TODO FIX this + async updateUserPermissions(username, sitewidePermissions) { + try { + const data = await this.http. + post(`/permissions/updateUserSitewidePermissions/${username}`, {sitewidePermissions}, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body; + } + } catch (error) { + console.error(error); + return {groupPermissions:[], sitewidePermissions:[]} + } + } + + async addNewRoleToGroup(groupId, data) { + try { + const result = await this.http. + post(`/permissions/addRoleToGroup/${groupId}`, {data}, {observe: 'response'}).toPromise(); + if (result.status === 200) { + return result.body; + } + } catch (error) { + console.error(error); + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + if (error.status === 409) { + this.errorHandler.handleError(_TRANSLATE(error.error)); + } + } + } + async updateRoleInGroup(groupId, role) { + try { + const result = await this.http. + post(`/permissions/updateRoleInGroup/${groupId}`, role, {observe: 'response'}).toPromise(); + if (result.status === 200) { + return result.body; + } + } catch (error) { + console.error(error); + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + if (error.status === 409) { + this.errorHandler.handleError(_TRANSLATE(error.error)); + } + } + } + + async getAllRoles(groupId) { + try { + const data = await this.http.get(`/rolesByGroupId/${groupId}/roles`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body['data']; + } + } catch (error) { + console.error(error); + return []; + } + } + async findRoleByName(groupId, roleName) { + try { + const data = await this.http.get(`/rolesByGroupId/${groupId}/role/${roleName}`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body['data']; + } + } catch (error) { + console.error(error); + return {}; + } + } + + getUserGroupPermissions(groupId) { + const allGroupsPermissions = JSON.parse(localStorage.getItem('permissions'))?.groupPermissions ?? []; + const groupPermissions = (allGroupsPermissions.find(group => group.groupName === groupId)).permissions; + return groupPermissions; + } + doesUserHaveAPermission(groupId, permission) { + const groupPermissions = this.getUserGroupPermissions(groupId); + return groupPermissions.includes(permission); + } + doesUserHaveAllPermissions(groupId, permissions= []) { + const groupPermissions = this.getUserGroupPermissions(groupId); + return permissions.every(e => groupPermissions.includes(e)); + } + + doesUserHaveSomePermissions(groupId, permissions) { + const groupPermissions = this.getUserGroupPermissions(groupId); + return permissions.some(e => groupPermissions.includes(e)); + } + + async getCustomLoginMarkup() { + try { + return await this.http.get('/custom-login-markup', {responseType: 'text'}).toPromise() + } catch (error) { + console.error('No custom-login-markup found'); + return ''; + } + } +} diff --git a/online-survey-app/src/app/core/auth/_services/user.service.spec.ts b/online-survey-app/src/app/core/auth/_services/user.service.spec.ts new file mode 100644 index 0000000000..b26195c25d --- /dev/null +++ b/online-survey-app/src/app/core/auth/_services/user.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserService] + }); + }); + + it('should be created', inject([UserService], (service: UserService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/online-survey-app/src/app/core/auth/_services/user.service.ts b/online-survey-app/src/app/core/auth/_services/user.service.ts new file mode 100644 index 0000000000..100d81d8d5 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_services/user.service.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { TangyErrorHandler } from 'src/app/shared/_services/tangy-error-handler.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; + +@Injectable() +export class UserService { + + constructor(private httpClient: HttpClient, private errorHandler: TangyErrorHandler, private http: HttpClient) { } + + async createUser(payload) { + try { + if (!(await this.doesUserExist(payload.username))) { + const result = await this.httpClient.post('/users/register-user', payload).toPromise(); + return result; + } + + } catch (error) { + this.showError(error); + } + + } + + async doesUserExist(username: string) { + try { + const result: any = await this.httpClient.get(`/users/userExists/${username}`).toPromise(); + return result.data; + } catch (error) { + this.showError(error); + + } + } + + async getAllUsers() { + try { + const users: any = await this.httpClient.get('/users').toPromise(); + return users.data; + } catch (error) { + this.showError(error); + + } + } + + async getCurrentUser() { + return await localStorage.getItem('user_id'); + } + private showError(error: any) { + console.log(error); + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + + async canManageSitewideUsers() { + return await this.http.get('/user/permission/can-manage-sitewide-users').toPromise() + } + + async getMyUser() { + try { + if (localStorage.getItem('user_id') === 'user1') { + return { + email: 'user1@tangerinecentral.org', + firstName: 'user1', + lastName: 'user1', + username: 'user1', + _id: 'user1' + } + } else { + const data = await this.http.get(`/users/findMyUser/`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body['data']; + } + } + } catch (error) { + console.error(error); + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + + async getAUserByUsername(username) { + try { + const data = await this.http.get(`/users/findOneUser/${username}`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.body['data']; + } + } catch (error) { + console.error(error); + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + + async searchUsersByUsername(username: string) { + try { + const data: any = await this.httpClient + .get(`/users/byUsername/${username}`) + .toPromise(); + return data.data; + } catch (error) { + console.error(error); + if (typeof error.status === 'undefined') { + this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); + } + } + } + + async deleteUser(username: string) { + try { + const data = await this.http.delete(`/users/delete/${username}`, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return true; + } + } catch (error) { + console.error(error); + return false; + } + } + async restoreUser(username: string) { + try { + const data = await this.http.patch(`/users/restore/${username}`, {isActive: true}, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return true; + } + } catch (error) { + console.error(error); + return false; + } + } + + async updateUserDetails(payload) { + try { + const data = await this.httpClient.put(`/users/update/${payload.username}`, payload, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.status; + } + } catch (error) { + console.error(error); + return 500; + } + } + async updateMyUser(payload) { + try { + const data = await this.httpClient.put(`/users/updateMyUser/`, + payload, {observe: 'response'}).toPromise(); + if (data.status === 200) { + return data.status; + } + } catch (error) { + console.error(error); + return 500; + } + } + +} diff --git a/online-survey-app/src/app/core/auth/auth-routing.module.ts b/online-survey-app/src/app/core/auth/auth-routing.module.ts new file mode 100644 index 0000000000..5e01fdbcf7 --- /dev/null +++ b/online-survey-app/src/app/core/auth/auth-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { LoginComponent } from './_components/login/login.component'; +import { UserRegistrationComponent } from './_components/user-registration/user-registration.component'; + +const routes: Routes = [ + { + path: 'register-user', + component: UserRegistrationComponent + }, + { + path: 'login', + component: LoginComponent + } +]; +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AuthRoutingModule { } diff --git a/online-survey-app/src/app/core/auth/auth.module.ts b/online-survey-app/src/app/core/auth/auth.module.ts new file mode 100644 index 0000000000..3c8985c5f6 --- /dev/null +++ b/online-survey-app/src/app/core/auth/auth.module.ts @@ -0,0 +1,43 @@ +import { CommonModule } from '@angular/common'; +import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SharedModule } from '../../shared/shared.module'; +import { AuthRoutingModule } from './auth-routing.module'; +import { LoginComponent } from './_components/login/login.component'; +import { UserRegistrationComponent } from './_components/user-registration/user-registration.component'; +import { LoginGuard } from './_guards/login-guard.service'; +import { AuthenticationService } from './_services/authentication.service'; +import { UserService } from './_services/user.service'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; + +@NgModule({ + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [ + CommonModule, + SharedModule, + MatListModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatCardModule, + FormsModule, + ReactiveFormsModule, + BrowserAnimationsModule, + AuthRoutingModule, + MatAutocompleteModule, + MatTableModule, + MatCheckboxModule + + ], + declarations: [UserRegistrationComponent, LoginComponent], + providers: [AuthenticationService, LoginGuard, UserService] +}) +export class AuthModule { } diff --git a/online-survey-app/src/app/shared/_factories/db.factory.ts b/online-survey-app/src/app/shared/_factories/db.factory.ts new file mode 100644 index 0000000000..7cc9177b48 --- /dev/null +++ b/online-survey-app/src/app/shared/_factories/db.factory.ts @@ -0,0 +1,94 @@ +// @ts-ignore +import PouchDB from 'pouchdb'; +// @ts-ignore +// import * as PouchDBFind from 'pouchdb-find'; +import * as cordovaSqlitePlugin from 'pouchdb-adapter-cordova-sqlite'; +import CryptoPouch from 'crypto-pouch'; +import * as PouchDBUpsert from 'pouchdb-upsert'; +import debugPouch from 'pouchdb-debug'; +PouchDB.plugin(debugPouch); +import PouchDBFind from 'pouchdb-find'; +PouchDB.plugin(PouchDBFind); +PouchDB.plugin(PouchDBUpsert); +PouchDB.plugin(cordovaSqlitePlugin); +PouchDB.plugin(window['PouchReplicationStream'].plugin); +let PouchDBLoad = (window as { [key: string]: any })["PouchDBLoad"]; +PouchDB.plugin({ + loadIt: PouchDBLoad.load +}); +PouchDB.adapter('writableStream', window['PouchReplicationStream'].adapters.writableStream); +const defaults = {} +PouchDB.plugin(CryptoPouch) + +export function connectToSqlCipherDb(name, key = ''):PouchDB { + let pouchDBOptions = { + adapter: 'cordova-sqlite', + location: 'default', + androidDatabaseImplementation: 2 + }; + if (key) { + pouchDBOptions.key = key + } + if (window['changes_batch_size'] && name === 'shared-user-database') { + pouchDBOptions.changes_batch_size = window['changes_batch_size'] + } + try { + const pouch = new PouchDB(name, {...defaults, ...pouchDBOptions}); + return pouch + } catch (e) { + console.log("Database error: " + e); + console.trace(); + } +} + +export function connectToCryptoPouchDb(name, key = ''):PouchDB { + let pouchDBOptions = {} + if (window['isCordovaApp']) { + pouchDBOptions = { + view_adapter: 'cordova-sqlite', + location: 'default', + androidDatabaseImplementation: 2 + } + if (key) { + pouchDBOptions.key = key + } + } + if (window['changes_batch_size'] && name === 'shared-user-database') { + pouchDBOptions.changes_batch_size = window['changes_batch_size'] + } + try { + const pouch = new PouchDB(name, {...defaults, ...pouchDBOptions}); + if (key) { + pouch.crypto(key) + pouch.cryptoPouchIsEnabled = true + } + return pouch + } catch (e) { + console.log("Database error: " + e); + console.trace(); + } +} + +export function connectToPouchDb(name):PouchDB { + const pouchDBOptions = {} + if (window['changes_batch_size'] && name === 'shared-user-database') { + pouchDBOptions.changes_batch_size = window['changes_batch_size'] + } + try { + const pouch = new PouchDB(name, {...defaults, ...pouchDBOptions}); + return pouch + } catch (e) { + console.log("Database error: " + e); + console.trace(); + } +} + +export function DB(name, key = ''):PouchDB { + if (window['cryptoPouchRunning']) { + return connectToCryptoPouchDb(name, key) + } else if (window['sqlCipherRunning']) { + return connectToSqlCipherDb(name, key) + } else { + return connectToPouchDb(name) + } +} diff --git a/online-survey-app/src/app/shared/_services/forms-service.service.ts b/online-survey-app/src/app/shared/_services/forms-service.service.ts index 81250f48c6..50b65e9a81 100644 --- a/online-survey-app/src/app/shared/_services/forms-service.service.ts +++ b/online-survey-app/src/app/shared/_services/forms-service.service.ts @@ -37,15 +37,15 @@ export class FormsServiceService { async uploadFormResponse(formResponse): Promise{ try { - const {formUploadURL, groupId, uploadKey} = await this.appConfigService.getAppConfig(); + const config = await this.appConfigService.getAppConfig(); // Set the groupId or it will be missing from the form // TODO: Move this logic to tangy-form so it happens for all responses - formResponse.groupId = groupId + formResponse.groupId = config.groupId const headers = new HttpHeaders(); - headers.set('formUploadToken', uploadKey); - const data = await this.httpClient.post(formUploadURL, formResponse, {headers, observe: 'response'}).toPromise(); + headers.set('formUploadToken', config.uploadKey); + const data = await this.httpClient.post(config.formUploadURL, formResponse, {headers, observe: 'response'}).toPromise(); return data.status === 200; } catch (error) { console.error(error); diff --git a/online-survey-app/src/app/shared/_services/menu.service.ts b/online-survey-app/src/app/shared/_services/menu.service.ts new file mode 100644 index 0000000000..b6588bbd3c --- /dev/null +++ b/online-survey-app/src/app/shared/_services/menu.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class MenuService { + + title = '' + groupId:string = '' + section:string = '' + + constructor() { } + + setContext(title = '', groupId = '', section = '',) { + this.title = title + this.section = section + this.groupId = groupId + } + +} diff --git a/online-survey-app/src/app/shared/_services/tangy-error-handler.service.ts b/online-survey-app/src/app/shared/_services/tangy-error-handler.service.ts new file mode 100644 index 0000000000..142614140d --- /dev/null +++ b/online-survey-app/src/app/shared/_services/tangy-error-handler.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { _TRANSLATE } from './translation-marker'; + +@Injectable() +export class TangyErrorHandler { + + constructor(private snackbar: MatSnackBar) { } + public handleError(error: string, duration = 10000) { + this.snackbar.open(error, _TRANSLATE('Close'), { duration }); + } +} diff --git a/online-survey-app/src/app/shared/_services/translation-marker.ts b/online-survey-app/src/app/shared/_services/translation-marker.ts new file mode 100644 index 0000000000..33ee2ce040 --- /dev/null +++ b/online-survey-app/src/app/shared/_services/translation-marker.ts @@ -0,0 +1,3 @@ +export function _TRANSLATE(str: string) { + return str; +} \ No newline at end of file diff --git a/online-survey-app/src/app/shared/classes/app-config.class.ts b/online-survey-app/src/app/shared/classes/app-config.class.ts new file mode 100644 index 0000000000..2479a5589b --- /dev/null +++ b/online-survey-app/src/app/shared/classes/app-config.class.ts @@ -0,0 +1,197 @@ +export class AppConfig { + + // + // Tangerine Flavor + // + + // The homeUrl determines which Tangerine Flavor. Options are as follows. + // + // Tangerine Coach: 'case-management' + // Tangerine Case Management: 'case-home' + // Tangerine Teach: 'dashboard' + homeUrl = "case-management" + // customLoginMarkup Custom Markup to include on the login page + customLoginMarkup: string + // + // i18n configuration. + // + + languageDirection = "ltr" + languageCode = "en" + useEthiopianCalendar:boolean + // Format of the date to be displayed in the application. Use Moment's Format standard. https://momentjs.com/docs/#/displaying/format/ + dateFormat = "M/D/YYYY" + + // + // Sync configuration + // + + serverUrl = "http://localhost/" + syncProtocol = '1' + groupId:string + groupName:string + + // + // Sync Protocol 1 configuration. + // + + uploadToken = "change_this_token" + uploadUrl = '' + uploadUnlockedFormReponses = false + usageCleanupBatchSize + minimumFreeSpace + + // + // Sync Protocol 2 configuration. + // + + // In a sync batch, control the number of database records read, sent over the network, and written to the database. Tweak this down when using the SqlCipher encryption plugin to avoid database crashes. + batchSize:number + // On a Devices first sync. Control in a batch the number of database records read, sent over the network, and written to the database. Tweak this down when using the SqlCipher encryption plugin to avoid database crashes. + initialBatchSize:number + // The max number of documents that will get written to disk during a sync. Tweak this down when using the SqlCipher encryption plugin to avoid database crashes. + writeBatchSize:number + // The max number of documents that will indexed at a time. Tweak this down when using the SqlCipher encryption plugin to avoid database crashes. + changesBatchSize:number + // The number of IDs to read from the database at a time when doing a Comparison Sync. + compareLimit: number; + // List of views to skip optimization of after a sync. + doNotOptimize: Array + // Prevent database optimization after a sync other than the first sync. This is not recommended, will lead to performance issues when using Devices. + indexViewsOnlyOnFirstSync:boolean = false + // Enables support for reducing the number of documents processed in the changed feed when syncing by passing this value to pouchDBOptions used when instantiating a db connection in db.factory.ts. This setting can help sites that experience crashes when syncing or indexing documents. Using this setting *will* slow sync times. (default: 50) + changes_batch_size:number + + // + // Account auth configuration. + // + + listUsernamesOnLoginScreen = true + securityQuestionText = "What is your year of birth?" + // Regex used to validate passwords for Device accounts. + passwordPolicy:string + // Description of validation for passwords of Device accounts. + passwordRecipe:string + // Disregard password functionality. + noPassword = false + // Prevent users from logging out and accessing menu. Useful when used in conjunction with a kiosk mode app. + kioskMode = false + // How many clicks to exit kioskMode? + exitClicks:number + + // + // Profile configuration. + // + + // centrallyManagedUserProfile and registrationRequiresServerUser are mutually exclusive. If both are set to true, the app will default to using centrallyManagedUserProfile. + // The user profile is managed by the server and not the Device. This setting is only applicable to Sync Protocol 1. + centrallyManagedUserProfile = false + // The user profile is initially managed by the server and can be updated on the client. This setting is only applicable to Sync Protocol 2. + registrationRequiresServerUser = false + // Hides the user profile link to edit when on the Device. + hideProfile = false + // Hides the about page. + hideAbout = false + // When using Sync Protocol 2 and associating a new Device Account with a Device User, setting this to true will show them all Device Users synced + // down to the Device rather than filtering those Device Users based on the single Device Assignment. + disableDeviceUserFilteringByAssignment:boolean + + // + // Encryption configuration. + // + + // Options are "SqlCipher" or "CryptoPouch". "SqlCipher" is the default but "CryptoPouch" is probably more stable. + encryptionPlugin:EncryptionPlugin + // Turns off all app level encryption. App will then report as depending on System (disk) level encryption. + turnOffAppLevelEncryption:boolean + + // + // GPS configuration. + // + + // Don't collect much GPS data and want to save battery? Set this to true to prevent the GPS chip from running constantly. + disableGpsWarming:boolean + + // + // Tangerine Case module configuration. + // + + showQueries:boolean + showCaseReports:boolean + // Determines wether or not the Issues tab is shown on the case module's home screen. + showIssues:boolean + barcodeSearchMapFunction:string + // Determines if a "Create Issue" button appears when viewing submitted Event Forms. + allowCreationOfIssues:boolean + // Determines if a "Merge Issue" button appears in the Issues Revision page. + allowMergeOfIssues:boolean + filterCaseEventScheduleByDeviceAssignedLocation:boolean = false + + + // + // Tangerine Coach configuration. + // + + columnsOnVisitsTab = [] + categories = [] + + // + // Teach configuration properties + // + + teachProperties = { + units: [], + unitDates: [], + cutoffRange:10, + attendancePrimaryThreshold: 80, + attendanceSecondaryThreshold: 70, + scoringPrimaryThreshold: 70, + scoringSecondaryThreshold: 60, + behaviorPrimaryThreshold: 90, + behaviorSecondaryThreshold: 80, + useAttendanceFeature: false, + showAttendanceCalendar: false, + studentRegistrationFields:[], + showLateAttendanceOption: false + } + + // + // Custom App configuration + // + + goHomeAfterFormSubmit = false + forceCompleteForms = false + + // + // Backup configuration. + // + + useCachedDbDumps:boolean + dbBackupSplitNumberFiles: number; + + // Media files configuration + mediaFileStorageLocation:string // Options: 'database' (default) or 'file'. If undefined, will save to database. + + // + // Experimental configuration. + // + + // Use experimental mode in Tangy Form that only captures the properties of inputs that have changed from their original state in the form. + saveLessFormData:boolean + p2pSync = 'false' + attachHistoryToDocs:boolean = false + usePouchDbLastSequenceTracking:boolean + forceNewEventFormConfirmation:boolean = false + + // + // @TODO Sort these. + // + + calculateLocalDocsForLocation:boolean; + +} + +export enum EncryptionPlugin { + SqlCipher = 'SqlCipher', + CryptoPouch = 'CryptoPouch' +} diff --git a/online-survey-app/src/app/shared/classes/user-account.class.ts b/online-survey-app/src/app/shared/classes/user-account.class.ts new file mode 100644 index 0000000000..a729af5e6c --- /dev/null +++ b/online-survey-app/src/app/shared/classes/user-account.class.ts @@ -0,0 +1,12 @@ +export class UserAccount { + _id:string + username:string + password:string + securityQuestionResponse:string + userUUID:string + initialProfileComplete:boolean + constructor(data:any) { + Object.assign(this, data) + this.username = data?._id + } +} \ No newline at end of file diff --git a/online-survey-app/src/app/shared/classes/user-database.class.ts b/online-survey-app/src/app/shared/classes/user-database.class.ts new file mode 100644 index 0000000000..50b0d34932 --- /dev/null +++ b/online-survey-app/src/app/shared/classes/user-database.class.ts @@ -0,0 +1,148 @@ +import {_TRANSLATE} from '../translation-marker'; +const SHARED_USER_DATABASE_NAME = 'shared-user-database'; +import PouchDB from 'pouchdb'; +import { DB } from '../_factories/db.factory'; +import * as jsonpatch from "fast-json-patch"; + +export class UserDatabase { + + userId: string; + username: string; + name: string; + deviceId: string; + buildId:string; + buildChannel:string; + groupId:string; + db: PouchDB; + attachHistoryToDocs:boolean + + constructor(username: string, userId: string, key:string = '', deviceId: string, shared = false, buildId = '', buildChannel = '', groupId = '', attachHistoryToDocs = false) { + this.userId = userId + this.username = username + this.name = username + this.deviceId = deviceId + this.buildId = buildId + this.buildChannel = buildChannel + this.groupId = groupId + this.attachHistoryToDocs = attachHistoryToDocs + if (shared) { + this.db = DB(SHARED_USER_DATABASE_NAME, key) + } else { + this.db = DB(username, key) + } + } + + async synced(doc) { + return await this.db.put({ + ...doc, + tangerineSyncedOn: Date.now() + }); + } + + async get(_id) { + const doc = await this.db.get(_id); + // @TODO Temporary workaround for CryptoPouch bug where it doesn't include the _rev when getting a doc. + if (this.db.cryptoPouchIsEnabled) { + const tmpDb = new PouchDB(this.db.name) + const encryptedDoc = await tmpDb.get(_id) + doc._rev = encryptedDoc._rev + } + return doc + } + + async put(doc) { + const newDoc = { + ...doc, + tangerineModifiedByUserId: this.userId, + tangerineModifiedByDeviceId: this.deviceId, + tangerineModifiedOn: Date.now(), + buildId: this.buildId, + deviceId: this.deviceId, + groupId: this.groupId, + buildChannel: this.buildChannel, + // Backwards compatibility for sync protocol 1. + lastModified: Date.now() + } + return await this.db.put({ + ...newDoc, + ...this.attachHistoryToDocs + ? { history: await this._calculateHistory(newDoc) } + : { } + }); + } + + async post(doc) { + const newDoc = { + ...doc, + tangerineModifiedByUserId: this.userId, + tangerineModifiedByDeviceId: this.deviceId, + tangerineModifiedOn: Date.now(), + buildId: this.buildId, + deviceId: this.deviceId, + groupId: this.groupId, + buildChannel: this.buildChannel, + // Backwards compatibility for sync protocol 1. + lastModified: Date.now() + } + return await this.db.post({ + ...newDoc, + ...this.attachHistoryToDocs + ? { history: await this._calculateHistory(newDoc) } + : { } + }); + } + + remove(doc) { + return this.db.remove(doc); + } + + query(queryName: string, options = {}) { + return this.db.query(queryName, options); + } + + destroy() { + return this.db.destroy(); + } + + changes(options) { + return this.db.changes(options); + } + + allDocs(options) { + return this.db.allDocs(options); + } + + sync(remoteDb, options) { + return this.db.sync(remoteDb, options); + } + + upsert(docId, callback) { + return this.db.upsert(docId, callback); + } + + compact() { + return this.db.compact(); + } + + async _calculateHistory(newDoc) { + let history = [] + try { + const currentDoc = await this.db.get(newDoc._id) + const entry = { + lastRev: currentDoc._rev, + patch: jsonpatch.compare(currentDoc, newDoc).filter(mod => mod.path.substr(0,8) !== '/history') + } + history = currentDoc.history + ? [ entry, ...currentDoc.history ] + : [ entry ] + } catch (e) { + const entry = { + lastRev: 0, + patch: jsonpatch.compare({}, newDoc).filter(mod => mod.path.substr(0,8) !== '/history') + } + history = [ entry ] + } + return history + } + +} diff --git a/online-survey-app/src/app/shared/classes/user-signup.class.ts b/online-survey-app/src/app/shared/classes/user-signup.class.ts new file mode 100644 index 0000000000..477f278f86 --- /dev/null +++ b/online-survey-app/src/app/shared/classes/user-signup.class.ts @@ -0,0 +1,10 @@ +export class UserSignup { + adminPassword:string = '' + username:string = '' + password:string = '' + confirmPassword:string = '' + securityQuestionResponse:string = '' + constructor(data) { + Object.assign(this, data) + } +} \ No newline at end of file diff --git a/online-survey-app/src/app/shared/shared.module.ts b/online-survey-app/src/app/shared/shared.module.ts new file mode 100644 index 0000000000..beb8d34855 --- /dev/null +++ b/online-survey-app/src/app/shared/shared.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { HttpClientModule, HttpClient } from '@angular/common/http'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateHttpLoader } from '@ngx-translate/http-loader'; +export function HttpClientLoaderFactory(httpClient: HttpClient) { + return new TranslateHttpLoader(httpClient, './assets/', '.json'); +} + +@NgModule({ + schemas: [ CUSTOM_ELEMENTS_SCHEMA ], + imports: [ + CommonModule, + HttpClientModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useFactory: HttpClientLoaderFactory, + deps: [HttpClient] + } + }) + ], + providers: [ + ], + declarations: [ + ], + exports: [TranslateModule] + }) + export class SharedModule { } + \ No newline at end of file From 8971b53372414898f8777dd5899dfe58b3982c41 Mon Sep 17 00:00:00 2001 From: esurface Date: Thu, 23 May 2024 15:07:48 -0400 Subject: [PATCH 02/42] Add online survey get and save form response from case event form id --- develop.sh | 1 + .../src/app/app-routing.module.ts | 3 +- .../_services/forms-service.service.spec.ts | 8 +-- .../shared/_services/forms-service.service.ts | 58 ++++++++++++++++- .../tangy-forms-player.component.ts | 46 +++++++++++--- server/src/case-api.js | 29 +++++++++ server/src/express-app.js | 11 +++- server/src/online-survey.js | 62 +++++++++++++++++-- 8 files changed, 196 insertions(+), 22 deletions(-) create mode 100644 server/src/case-api.js diff --git a/develop.sh b/develop.sh index a7af59e3cb..76ede2af08 100755 --- a/develop.sh +++ b/develop.sh @@ -230,6 +230,7 @@ OPTIONS="--link $T_COUCHDB_CONTAINER_NAME:couchdb \ --volume $(pwd)/editor/src:/tangerine/editor/src:delegated \ --volume $(pwd)/translations:/tangerine/translations:delegated \ --volume $(pwd)/online-survey-app/src:/tangerine/online-survey-app/src:delegated \ + --volume $(pwd)/online-survey-app/dist:/tangerine/online-survey-app/dist:delegated \ --volume $(pwd)/tangy-form-editor:/tangerine/tangy-form-editor:delegated \ --volume $(pwd)/tangy-form:/tangerine/tangy-form:delegated \ tangerine/tangerine:local diff --git a/online-survey-app/src/app/app-routing.module.ts b/online-survey-app/src/app/app-routing.module.ts index 840288ee4b..d93f6569d3 100644 --- a/online-survey-app/src/app/app-routing.module.ts +++ b/online-survey-app/src/app/app-routing.module.ts @@ -8,8 +8,9 @@ import { LoginGuard } from './core/auth/_guards/login-guard.service'; const routes: Routes = [ { path: 'forms-list', component: FormsListComponent, canActivate: [LoginGuard] }, { path: 'form-submitted-success', component: FormSubmittedSuccessComponent, canActivate: [LoginGuard] }, - { path: 'form/:id', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, + { path: 'form/:formId', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, { path: 'form/option/:formId/:option', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, + { path: 'caseFormResponse/:caseEventFormId', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, ]; @NgModule({ diff --git a/online-survey-app/src/app/shared/_services/forms-service.service.spec.ts b/online-survey-app/src/app/shared/_services/forms-service.service.spec.ts index 589605dd51..d0a0f4d907 100644 --- a/online-survey-app/src/app/shared/_services/forms-service.service.spec.ts +++ b/online-survey-app/src/app/shared/_services/forms-service.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { FormsServiceService } from './forms-service.service'; +import { FormsService } from './forms-service.service'; -describe('FormsServiceService', () => { - let service: FormsServiceService; +describe('FormsService', () => { + let service: FormsService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(FormsServiceService); + service = TestBed.inject(FormsService); }); it('should be created', () => { diff --git a/online-survey-app/src/app/shared/_services/forms-service.service.ts b/online-survey-app/src/app/shared/_services/forms-service.service.ts index 50b65e9a81..ee4ddb17ff 100644 --- a/online-survey-app/src/app/shared/_services/forms-service.service.ts +++ b/online-survey-app/src/app/shared/_services/forms-service.service.ts @@ -2,13 +2,18 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Form } from '../classes/form'; import { AppConfigService } from './app-config.service'; +import { AuthenticationService } from 'src/app/core/auth/_services/authentication.service'; @Injectable({ providedIn: 'root' }) -export class FormsServiceService { +export class FormsService { - constructor(private httpClient: HttpClient, private appConfigService: AppConfigService) { } + constructor( + private httpClient: HttpClient, + private appConfigService: AppConfigService, + private authenticationService: AuthenticationService + ) { } async getForms(): Promise { try { @@ -35,6 +40,36 @@ export class FormsServiceService { } } + async getFormResponse(formResponseId: string): Promise { + try { + const config = await this.appConfigService.getAppConfig(); + + const headers = new HttpHeaders(); + headers.set('formUploadToken', config.uploadKey); + const url = `/onlineSurvey/getResponse/${config.groupId}/${formResponseId}`; + const data = await this.httpClient.get(url, {headers}).toPromise(); + + return data; + } catch (error) { + console.error(error); + } + } + + async getEventFormData(eventFormId: string): Promise { + try { + const config = await this.appConfigService.getAppConfig(); + + const headers = new HttpHeaders(); + headers.set('formUploadToken', config.uploadKey); + const url = `/case/getEventFormData/${config.groupId}/${eventFormId}`; + const data = await this.httpClient.get(url, {headers}).toPromise(); + + return data; + } catch (error) { + console.error(error); + } + } + async uploadFormResponse(formResponse): Promise{ try { const config = await this.appConfigService.getAppConfig(); @@ -52,4 +87,23 @@ export class FormsServiceService { return false; } } + + async uploadFormResponseForCase(formResponse, eventFormId): Promise{ + try { + const config = await this.appConfigService.getAppConfig(); + + // Set the groupId or it will be missing from the form + // TODO: Move this logic to tangy-form so it happens for all responses + formResponse.groupId = config.groupId + + const headers = new HttpHeaders(); + const formUploadURL = `/onlineSurvey/saveResponse/${config.groupId}/${eventFormId}/${formResponse._id}`; + headers.set('formUploadToken', config.uploadKey); + const data = await this.httpClient.post(formUploadURL, formResponse, {headers, observe: 'response'}).toPromise(); + return data.status === 200; + } catch (error) { + console.error(error); + return false; + } + } } diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts b/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts index d32e305593..b3d3a7eccf 100644 --- a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts +++ b/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { FormsServiceService } from '../shared/_services/forms-service.service'; +import { FormsService } from '../shared/_services/forms-service.service'; @Component({ selector: 'app-tangy-forms-player', @@ -10,21 +10,51 @@ import { FormsServiceService } from '../shared/_services/forms-service.service'; }) export class TangyFormsPlayerComponent implements OnInit { @ViewChild('container', {static: true}) container: ElementRef; - constructor(private route: ActivatedRoute, private formsService: FormsServiceService, private router: Router, - private httpClient:HttpClient - ) { } + + formId: string; + formResponseId: string; + caseEventFormId: string; + + constructor(private route: ActivatedRoute, private formsService: FormsService, private router: Router, private httpClient:HttpClient + ) { + this.router.events.subscribe(async (event) => { + this.formId = this.route.snapshot.paramMap.get('formId'); + this.formResponseId = this.route.snapshot.paramMap.get('formResponseId'); + this.caseEventFormId = this.route.snapshot.paramMap.get('caseEventFormId'); + }); + } async ngOnInit(): Promise { const data = await this.httpClient.get('./assets/form/form.html', {responseType: 'text'}).toPromise(); this.container.nativeElement.innerHTML = data; - const tangyForm = this.container.nativeElement.querySelector('tangy-form'); + let tangyForm = this.container.nativeElement.querySelector('tangy-form'); + + if (this.caseEventFormId) { + try { + const eventForm = await this.formsService.getEventFormData(this.caseEventFormId); + if (eventForm && eventForm.formResponseId) { + tangyForm.response = await this.formsService.getFormResponse(eventForm.formResponseId); + } + } catch (error) { + //pass + } + } + tangyForm.addEventListener('after-submit', async (event) => { event.preventDefault(); try { - if (await this.formsService.uploadFormResponse(event.target.response)){ - this.router.navigate(['/form-submitted-success']); + if (this.caseEventFormId) { + if (await this.formsService.uploadFormResponseForCase(event.target.response, this.caseEventFormId)) { + this.router.navigate(['/form-submitted-success']); + } else { + alert('Form could not be submitted. Please retry'); + } } else { - alert('Form could not be submitted. Please retry'); + if (await this.formsService.uploadFormResponse(event.target.response)) { + this.router.navigate(['/form-submitted-success']); + } else { + alert('Form could not be submitted. Please retry'); + } } } catch (error) { console.error(error); diff --git a/server/src/case-api.js b/server/src/case-api.js new file mode 100644 index 0000000000..3aee6e6cd4 --- /dev/null +++ b/server/src/case-api.js @@ -0,0 +1,29 @@ +const DB = require('./db.js') +const clog = require('tangy-log').clog +const log = require('tangy-log').log + +getEventFormData = async (req, res) => { + const groupDb = new DB(req.params.groupId) + let data = {} + try { + let options = { key: req.params.eventFormId, include_docs: true } + const results = await groupDb.query('eventForms/eventForms', options); + if (results.rows.length > 0) { + const doc = results.rows[0].doc + for (let event of doc.events) { + let eventForm = event.eventForms.find((f) => f.id === req.params.eventFormId); + if (eventForm) { + data = eventForm + break; + } + } + } + } catch (err) { + res.status(500).send(err); + } + res.send(data) +} + +module.exports = { + getEventFormData +} \ No newline at end of file diff --git a/server/src/express-app.js b/server/src/express-app.js index ad69f9cd50..90dd3eddc4 100644 --- a/server/src/express-app.js +++ b/server/src/express-app.js @@ -46,7 +46,8 @@ const { extendSession, findUserByUsername, const {registerUser, getUserByUsername, isUserSuperAdmin, isUserAnAdminUser, getGroupsByUser, deleteUser, getAllUsers, checkIfUserExistByUsername, findOneUserByUsername, findMyUser, updateUser, restoreUser, updateMyUser} = require('./users'); - const {saveResponse: saveSurveyResponse, publishSurvey, unpublishSurvey} = require('./online-survey') +const {getResponse: getSurveyResponse, saveResponse: saveSurveyResponse, publishSurvey, unpublishSurvey} = require('./online-survey') +const { getEventFormData } = require('./case-api') log.info('heartbeat') setInterval(() => log.info('heartbeat'), 5*60*1000) const cookieParser = require('cookie-parser'); @@ -179,6 +180,11 @@ app.get('/users/groupPermissionsByGroupName/:groupName', isAuthenticated, getUse app.get('/configuration/archiveToDisk', isAuthenticated, archiveToDiskConfig); app.get('/configuration/passwordPolicyConfig', isAuthenticated, passwordPolicyConfig); +/** + * Case API routes + */ + +app.get('/case/getEventFormData/:groupId/:eventFormId', getEventFormData); /** * Online survey routes @@ -187,6 +193,9 @@ app.get('/users/groupPermissionsByGroupName/:groupName', isAuthenticated, getUse app.post('/onlineSurvey/publish/:groupId/:formId', isAuthenticated, publishSurvey); app.put('/onlineSurvey/unpublish/:groupId/:formId', isAuthenticated, unpublishSurvey); app.post('/onlineSurvey/saveResponse/:groupId/:formId', hasSurveyUploadKey, saveSurveyResponse); + +app.get('/onlineSurvey/getResponse/:groupId/:formResponseId', /* hasSurveyUploadKey,*/ getSurveyResponse); +app.post('/onlineSurvey/saveResponse/:groupId/:caseEventFormId/:formResponseId', /* hasSurveyUploadKey,*/ saveSurveyResponse); /* * More API */ diff --git a/server/src/online-survey.js b/server/src/online-survey.js index 9c44d03586..9ac726d581 100644 --- a/server/src/online-survey.js +++ b/server/src/online-survey.js @@ -3,17 +3,66 @@ const GROUPS_DB = new DB('groups'); const { v4: uuidV4 } = require('uuid'); const createFormKey = () => uuidV4(); +const getResponse = async (req, res) => { + try { + + const { groupId, formResponseId } = req.params; + + console.log('getResponse', groupId, formResponseId); + + const db = new DB(groupId); + const doc = await db.get(formResponseId); + return res.status(200).send(doc); + } catch (error) { + console.error(error); + return res.status(500).send('Could not get response'); + } +}; + const saveResponse = async (req, res) => { try { - const { groupId, formId } = req.params; + const { groupId, formId, caseEventFormId } = req.params; + + console.log('saveResponse', groupId, formId, caseEventFormId); + const db = new DB(groupId); const data = req.body; data.formId = formId; + + if (caseEventFormId) { + try { + const results = await db.query("eventForms/eventForms", {key: caseEventFormId, include_docs: true}); + if (results.rows.length > 0) { + const caseDoc = results.rows[0].doc; + console.log('caseId', caseDoc._id) + + for (let event of caseDoc.events) { + let eventForm = event.eventForms.find((f) => f.id === caseEventFormId); + if (eventForm) { + + console.log('eventForm', eventForm.id); + + data.caseId = caseDoc._id; + data.caseEventId = event.id; + data.caseEventFormId = eventForm.id; + + eventForm.formResponseId = data._id; + eventForm.complete = true + await db.put(caseDoc); + break; + } + } + } + } catch (error) { + console.error(error); + } + } + await db.post(data); return res.status(200).send({ data: 'Response Saved Successfully' }); } catch (error) { console.error(error); - return res.status(500).send('Could not save response'); + return res.status(500).send('Could not save response'); } }; @@ -66,8 +115,9 @@ const unpublishSurvey = async (req, res) => { } }; -module.exports ={ - saveResponse, - publishSurvey, - unpublishSurvey +module.exports = { + getResponse, + saveResponse, + publishSurvey, + unpublishSurvey } \ No newline at end of file From d4a713cf556142bc145fedb8941edfa0799433e4 Mon Sep 17 00:00:00 2001 From: esurface Date: Wed, 29 May 2024 16:07:14 -0400 Subject: [PATCH 03/42] Working version of online-survey login routing --- develop.sh | 7 +- .../src/app/app-routing.module.ts | 2 + .../auth/_components/login/login.component.ts | 10 +- .../survey-login/survey-login.component.css | 33 +++ .../survey-login/survey-login.component.html | 22 ++ .../survey-login/survey-login.component.ts | 55 +++++ .../core/auth/_guards/login-guard.service.ts | 2 +- .../auth/_services/authentication.service.ts | 189 +++--------------- .../src/app/core/auth/auth-routing.module.ts | 6 +- .../src/app/core/auth/auth.module.ts | 3 +- server/src/auth-utils.js | 10 + server/src/express-app.js | 3 +- server/src/online-survey.js | 31 +++ 13 files changed, 191 insertions(+), 182 deletions(-) create mode 100644 online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.css create mode 100644 online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.html create mode 100644 online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.ts diff --git a/develop.sh b/develop.sh index 76ede2af08..7182a9f976 100755 --- a/develop.sh +++ b/develop.sh @@ -77,11 +77,12 @@ if echo "$T_MODULES" | grep mysql; then ./mysql-create-dirs.sh fi -if echo "$T_USE_MYSQL_CONTAINER" | grep "true"; then + if echo "$T_USE_MYSQL_CONTAINER" | grep "true"; then ./mysql-start-container.sh echo "Waiting 60 seconds for mysql container to start..." - sleep 60 - ./mysql-setup.sh + sleep 60 + ./mysql-setup.sh + fi fi if echo "$T_MYSQL_PHPMYADMIN" | grep "TRUE"; then diff --git a/online-survey-app/src/app/app-routing.module.ts b/online-survey-app/src/app/app-routing.module.ts index d93f6569d3..7370b484d0 100644 --- a/online-survey-app/src/app/app-routing.module.ts +++ b/online-survey-app/src/app/app-routing.module.ts @@ -4,8 +4,10 @@ import { FormSubmittedSuccessComponent } from './form-submitted-success/form-sub import { FormsListComponent } from './forms-list/forms-list.component'; import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; import { LoginGuard } from './core/auth/_guards/login-guard.service'; +import { SurveyLoginComponent } from './core/auth/_components/survey-login/survey-login.component'; const routes: Routes = [ + { path: 'survey-login', component: SurveyLoginComponent }, { path: 'forms-list', component: FormsListComponent, canActivate: [LoginGuard] }, { path: 'form-submitted-success', component: FormSubmittedSuccessComponent, canActivate: [LoginGuard] }, { path: 'form/:formId', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, diff --git a/online-survey-app/src/app/core/auth/_components/login/login.component.ts b/online-survey-app/src/app/core/auth/_components/login/login.component.ts index 4ef807c3f9..b770f708de 100644 --- a/online-survey-app/src/app/core/auth/_components/login/login.component.ts +++ b/online-survey-app/src/app/core/auth/_components/login/login.component.ts @@ -5,7 +5,7 @@ import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; @Component({ - selector: 'app-login', + selector: 'login', templateUrl: './login.component.html', styleUrls: ['./login.component.css'] }) @@ -28,11 +28,7 @@ export class LoginComponent implements OnInit { if (await this.authenticationService.isLoggedIn()) { this.router.navigate([this.returnUrl]); - } - // We always want to log in from the "front door". If we log in from path of `/app/`, then the global cookie - // will be tied to that pathname and not available at other pathnames such as `/csv/` that is looking for authentication. - if (window.location.pathname !== '/') { - window.location.pathname = '/' + return; } this.customLoginMarkup.nativeElement.innerHTML = await this.authenticationService.getCustomLoginMarkup() this.ready = true @@ -42,8 +38,8 @@ export class LoginComponent implements OnInit { if (window.location.origin.startsWith('http://localhost')) { // If we are running on localhost, we want to use the local server for authentication + localStorage.setItem(this.user.username, this.user.password); this.router.navigate([this.returnUrl]); - } else if (await this.authenticationService.login(this.user.username, this.user.password)) { this.router.navigate([this.returnUrl]); } else { diff --git a/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.css b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.css new file mode 100644 index 0000000000..e347211a77 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.css @@ -0,0 +1,33 @@ +mat-card { + width: 300px; + margin: 30px auto; +} +.mat-title { + color: var(--primary-color); + font-size: 1.5em; + font-weight: 400; + font-family: Roboto, 'Helvetica Nue', sans-serif; +} +#logo { + margin-bottom: 15px; +} +mat-placeholder i { + margin-right: 0.075em; +} + +input.mat-input-element { + margin-top: 1em; +} + +.mat-placeholder-required.mat-form-field-required-marker.ng-tns-c6-2 + .mat-form-field-invalid + .mat-form-field-placeholder.mat-accent, +.mat-form-field-invalid + .mat-form-field-placeholder + .mat-form-field-required-marker { + position: relative; + bottom: 1em !important; +} +.mat-form-field { + width: 100%; +} diff --git a/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.html b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.html new file mode 100644 index 0000000000..2da30e3a12 --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.html @@ -0,0 +1,22 @@ + + Loading... + + +
+ + + + + key + {{'Access Code'|translate}} + + +
+
+ + + {{errorMessage}} + +
+
+ \ No newline at end of file diff --git a/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.ts b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.ts new file mode 100644 index 0000000000..01e461fedf --- /dev/null +++ b/online-survey-app/src/app/core/auth/_components/survey-login/survey-login.component.ts @@ -0,0 +1,55 @@ +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthenticationService } from '../../_services/authentication.service'; +import { _TRANSLATE } from 'src/app/shared/_services/translation-marker'; +import { AppConfigService } from 'src/app/shared/_services/app-config.service'; + + +@Component({ + selector: 'survey-login', + templateUrl: './survey-login.component.html', + styleUrls: ['./survey-login.component.css'] +}) +export class SurveyLoginComponent implements OnInit { + + errorMessage = ''; + returnUrl: string; // stores the value of the url to redirect to after login + user = { accessCode: '' }; + @ViewChild('customLoginMarkup', {static: true}) customLoginMarkup: ElementRef; + ready = false + + constructor( + private authenticationService: AuthenticationService, + private appConfigService: AppConfigService, + private route: ActivatedRoute, + private router: Router, + ) { } + + async ngOnInit() { + this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || 'forms-list'; + + if (await this.authenticationService.isLoggedIn()) { + this.router.navigate([this.returnUrl]); + return; + } + this.customLoginMarkup.nativeElement.innerHTML = await this.authenticationService.getCustomLoginMarkup() + this.ready = true + } + async loginUser() { + try { + + const appConfig = await this.appConfigService.getAppConfig(); + const groupId = appConfig['groupId']; + + if (await this.authenticationService.surveyLogin(groupId, this.user.accessCode)) { + this.router.navigate([this.returnUrl]); + } else { + this.errorMessage = _TRANSLATE('Login Unsuccessful'); + } + } catch (error) { + this.errorMessage = _TRANSLATE('Login Unsuccessful'); + console.error(error); + } + } + +} diff --git a/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts index 1b48299f84..b9986cbdef 100644 --- a/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts +++ b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts @@ -13,7 +13,7 @@ export class LoginGuard implements CanActivate { if (await this.authenticationService.isLoggedIn()) { return true; } - this.router.navigate([route, 'login'], { queryParams: { returnUrl: state.url } }); + this.router.navigate(['survey-login'], { queryParams: { returnUrl: state.url } }); return false; } } diff --git a/online-survey-app/src/app/core/auth/_services/authentication.service.ts b/online-survey-app/src/app/core/auth/_services/authentication.service.ts index 90395ebd19..b38084a918 100644 --- a/online-survey-app/src/app/core/auth/_services/authentication.service.ts +++ b/online-survey-app/src/app/core/auth/_services/authentication.service.ts @@ -13,9 +13,10 @@ export class AuthenticationService { constructor(private userService: UserService, private http: HttpClient, private errorHandler: TangyErrorHandler) { this.currentUserLoggedIn$ = new Subject(); } + async login(username: string, password: string) { try { - const data = await this.http.post('/login', {username, password}, {observe: 'response'}).toPromise(); + const data = await this.http.post('/login', {username, password}, {observe: 'response'}).toPromise(); if (data.status === 200) { const token = data.body['data']['token']; await this.setTokens(token); @@ -31,192 +32,48 @@ export class AuthenticationService { return false; } } - async isLoggedIn():Promise { - if (window.location.origin.startsWith('http://localhost')) { - localStorage.setItem('user_id', 'testuser'); + async surveyLogin(groupId: string, accessCode: string) { + try { + const data = await this.http.post(`/onlineSurvey/login/${groupId}/${accessCode}`, {groupId, accessCode}, {observe: 'response'}).toPromise(); + if (data.status === 200) { + const token = data.body['data']['token']; + await this.setTokens(token); + return true; + } else { + return false; + } + } catch (error) { + console.error(error); + localStorage.removeItem('token'); + localStorage.removeItem('user_id'); + return false; + } } + async isLoggedIn():Promise { this._currentUserLoggedIn = false; this._currentUserLoggedIn = !!localStorage.getItem('user_id'); this.currentUserLoggedIn$.next(this._currentUserLoggedIn); return this._currentUserLoggedIn; } - async validateSession():Promise { - const status = await this.http.get(`/login/validate/${localStorage.getItem('user_id')}`).toPromise() - return status['valid'] - } - - async logout() { - await localStorage.removeItem('token'); - await localStorage.removeItem('user_id'); - await localStorage.removeItem('password'); + localStorage.removeItem('token'); + localStorage.removeItem('user_id'); + localStorage.removeItem('password'); localStorage.removeItem('permissions'); document.cookie = "Authorization=;max-age=-1"; this._currentUserLoggedIn = false; this.currentUserLoggedIn$.next(this._currentUserLoggedIn); } - async extendUserSession() { - const username = localStorage.getItem('user_id'); - try { - const data = await this.http.post('/extendSession', {username}, {observe: 'response'}).toPromise(); - if (data.status === 200) { - const token = data.body['data']['token']; - await this.setTokens(token); - return true; - } else { - return false; - } - } catch (error) { - console.log(error); - } - } - async setTokens(token) { const jwtData = jwtDecode(token); document.cookie = "Authorization=;max-age=-1"; localStorage.setItem('token', token); - localStorage.setItem('user_id', jwtData['username']); - localStorage.setItem('permissions', JSON.stringify(jwtData['permissions'])); + localStorage.setItem('user_id', jwtData['accessCode']); document.cookie = `Authorization=${token}`; - - const user = await this.userService.getMyUser(); - window['userProfile'] = user; - window['userId'] = user._id; - window['username'] = jwtData['username']; - } - async getPermissionsList() { - try { - const data = await this.http.get('/permissionsList', {observe: 'response'}).toPromise(); - if (data.status === 200) { - return data.body; - } - } catch (error) { - console.error(error); - return {groupPermissions: [], sitewidePermissions: []} - } - } - async getSitewidePermissionsByUsername(username) { - try { - const data = await this.http.get(`/sitewidePermissionsByUsername/${username}`, {observe: 'response'}).toPromise(); - if (data.status === 200) { - return data.body; - } - } catch (error) { - console.error(error); - return {groupPermissions:[], sitewidePermissions:[]} - } - } - - async getUserGroupPermissionsByGroupName(groupName) { - try { - const data = await this.http.get(`/users/groupPermissionsByGroupName/${groupName}`, {observe: 'response'}).toPromise(); - if (data.status === 200) { - const token = data.body['data']['token']; - await this.setTokens(token); - return true; - } - } catch (error) { - console.error(error); - return false; - } - } - - setUserGroupPermissionsByGroupName(groupName) { - - } - // TODO FIX this - async updateUserPermissions(username, sitewidePermissions) { - try { - const data = await this.http. - post(`/permissions/updateUserSitewidePermissions/${username}`, {sitewidePermissions}, {observe: 'response'}).toPromise(); - if (data.status === 200) { - return data.body; - } - } catch (error) { - console.error(error); - return {groupPermissions:[], sitewidePermissions:[]} - } - } - - async addNewRoleToGroup(groupId, data) { - try { - const result = await this.http. - post(`/permissions/addRoleToGroup/${groupId}`, {data}, {observe: 'response'}).toPromise(); - if (result.status === 200) { - return result.body; - } - } catch (error) { - console.error(error); - if (typeof error.status === 'undefined') { - this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); - } - if (error.status === 409) { - this.errorHandler.handleError(_TRANSLATE(error.error)); - } - } - } - async updateRoleInGroup(groupId, role) { - try { - const result = await this.http. - post(`/permissions/updateRoleInGroup/${groupId}`, role, {observe: 'response'}).toPromise(); - if (result.status === 200) { - return result.body; - } - } catch (error) { - console.error(error); - if (typeof error.status === 'undefined') { - this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); - } - if (error.status === 409) { - this.errorHandler.handleError(_TRANSLATE(error.error)); - } - } - } - - async getAllRoles(groupId) { - try { - const data = await this.http.get(`/rolesByGroupId/${groupId}/roles`, {observe: 'response'}).toPromise(); - if (data.status === 200) { - return data.body['data']; - } - } catch (error) { - console.error(error); - return []; - } - } - async findRoleByName(groupId, roleName) { - try { - const data = await this.http.get(`/rolesByGroupId/${groupId}/role/${roleName}`, {observe: 'response'}).toPromise(); - if (data.status === 200) { - return data.body['data']; - } - } catch (error) { - console.error(error); - return {}; - } - } - - getUserGroupPermissions(groupId) { - const allGroupsPermissions = JSON.parse(localStorage.getItem('permissions'))?.groupPermissions ?? []; - const groupPermissions = (allGroupsPermissions.find(group => group.groupName === groupId)).permissions; - return groupPermissions; - } - doesUserHaveAPermission(groupId, permission) { - const groupPermissions = this.getUserGroupPermissions(groupId); - return groupPermissions.includes(permission); - } - doesUserHaveAllPermissions(groupId, permissions= []) { - const groupPermissions = this.getUserGroupPermissions(groupId); - return permissions.every(e => groupPermissions.includes(e)); - } - - doesUserHaveSomePermissions(groupId, permissions) { - const groupPermissions = this.getUserGroupPermissions(groupId); - return permissions.some(e => groupPermissions.includes(e)); } async getCustomLoginMarkup() { diff --git a/online-survey-app/src/app/core/auth/auth-routing.module.ts b/online-survey-app/src/app/core/auth/auth-routing.module.ts index 5e01fdbcf7..e0b5fa9b74 100644 --- a/online-survey-app/src/app/core/auth/auth-routing.module.ts +++ b/online-survey-app/src/app/core/auth/auth-routing.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { LoginComponent } from './_components/login/login.component'; +import { SurveyLoginComponent } from './_components/survey-login/survey-login.component'; import { UserRegistrationComponent } from './_components/user-registration/user-registration.component'; const routes: Routes = [ @@ -9,8 +9,8 @@ const routes: Routes = [ component: UserRegistrationComponent }, { - path: 'login', - component: LoginComponent + path: 'survey-login', + component: SurveyLoginComponent } ]; @NgModule({ diff --git a/online-survey-app/src/app/core/auth/auth.module.ts b/online-survey-app/src/app/core/auth/auth.module.ts index 3c8985c5f6..5e6cf3ba04 100644 --- a/online-survey-app/src/app/core/auth/auth.module.ts +++ b/online-survey-app/src/app/core/auth/auth.module.ts @@ -11,6 +11,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { SharedModule } from '../../shared/shared.module'; import { AuthRoutingModule } from './auth-routing.module'; import { LoginComponent } from './_components/login/login.component'; +import { SurveyLoginComponent } from './_components/survey-login/survey-login.component'; import { UserRegistrationComponent } from './_components/user-registration/user-registration.component'; import { LoginGuard } from './_guards/login-guard.service'; import { AuthenticationService } from './_services/authentication.service'; @@ -37,7 +38,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; MatCheckboxModule ], - declarations: [UserRegistrationComponent, LoginComponent], + declarations: [UserRegistrationComponent, LoginComponent, SurveyLoginComponent], providers: [AuthenticationService, LoginGuard, UserService] }) export class AuthModule { } diff --git a/server/src/auth-utils.js b/server/src/auth-utils.js index 819405b7fa..230b58217f 100644 --- a/server/src/auth-utils.js +++ b/server/src/auth-utils.js @@ -12,6 +12,15 @@ const createLoginJWT = ({ username, permissions }) => { return jwt.sign({ username, permissions }, jwtTokenSecret, signingOptions); }; +const createAccessCodeJWT = ({ accessCode }) => { + const signingOptions = { + expiresIn, + issuer, + subject: accessCode, + }; + return jwt.sign({ accessCode }, jwtTokenSecret, signingOptions); +}; + const verifyJWT = (token) => { try { const jwtPayload = jwt.verify(token, jwtTokenSecret, { issuer }); @@ -32,6 +41,7 @@ const decodeJWT = (token) => { module.exports = { createLoginJWT, + createAccessCodeJWT, decodeJWT, verifyJWT, }; diff --git a/server/src/express-app.js b/server/src/express-app.js index 90dd3eddc4..f0a41ef34e 100644 --- a/server/src/express-app.js +++ b/server/src/express-app.js @@ -46,7 +46,7 @@ const { extendSession, findUserByUsername, const {registerUser, getUserByUsername, isUserSuperAdmin, isUserAnAdminUser, getGroupsByUser, deleteUser, getAllUsers, checkIfUserExistByUsername, findOneUserByUsername, findMyUser, updateUser, restoreUser, updateMyUser} = require('./users'); -const {getResponse: getSurveyResponse, saveResponse: saveSurveyResponse, publishSurvey, unpublishSurvey} = require('./online-survey') +const {login: surveyLogin, getResponse: getSurveyResponse, saveResponse: saveSurveyResponse, publishSurvey, unpublishSurvey} = require('./online-survey') const { getEventFormData } = require('./case-api') log.info('heartbeat') setInterval(() => log.info('heartbeat'), 5*60*1000) @@ -194,6 +194,7 @@ app.post('/onlineSurvey/publish/:groupId/:formId', isAuthenticated, publishSurve app.put('/onlineSurvey/unpublish/:groupId/:formId', isAuthenticated, unpublishSurvey); app.post('/onlineSurvey/saveResponse/:groupId/:formId', hasSurveyUploadKey, saveSurveyResponse); +app.post('/onlineSurvey/login/:groupId/:accessCode', surveyLogin); app.get('/onlineSurvey/getResponse/:groupId/:formResponseId', /* hasSurveyUploadKey,*/ getSurveyResponse); app.post('/onlineSurvey/saveResponse/:groupId/:caseEventFormId/:formResponseId', /* hasSurveyUploadKey,*/ saveSurveyResponse); /* diff --git a/server/src/online-survey.js b/server/src/online-survey.js index 9ac726d581..26bdd66399 100644 --- a/server/src/online-survey.js +++ b/server/src/online-survey.js @@ -2,6 +2,36 @@ const DB = require('./db.js'); const GROUPS_DB = new DB('groups'); const { v4: uuidV4 } = require('uuid'); const createFormKey = () => uuidV4(); +const { createAccessCodeJWT } = require('./auth-utils'); + +const login = async (req, res) => { + try { + + const { groupId, accessCode } = req.params; + + console.log('login', groupId, accessCode); + + const groupDb = new DB(groupId) + let options = { key: accessCode, include_docs: true } + if (req.params.limit) { + options.limit = req.params.limit + } + if (req.params.skip) { + options.skip = req.params.skip + } + const results = await groupDb.query('responsesByUserProfileShortCode', options); + const docs = results.rows.map(row => row.doc) + const userProfileDoc = docs.find(doc => doc.form.id === 'user-profile'); + + if (userProfileDoc) { + const token = createAccessCodeJWT({"accessCode": accessCode}); + return res.status(200).send({ data: { token } }); + } + } catch (error) { + console.error(error); + return res.status(401).send({ data: 'Invalid Credentials' }); + } +} const getResponse = async (req, res) => { try { @@ -116,6 +146,7 @@ const unpublishSurvey = async (req, res) => { }; module.exports = { + login, getResponse, saveResponse, publishSurvey, From bc72f19d4b1660a406ebfc887c9622a59158c7c0 Mon Sep 17 00:00:00 2001 From: esurface Date: Thu, 30 May 2024 18:48:10 -0400 Subject: [PATCH 04/42] Fix release online survey script copying of form directory --- server/src/scripts/release-online-survey-app.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/scripts/release-online-survey-app.sh b/server/src/scripts/release-online-survey-app.sh index bcf88972bb..634393bd0a 100755 --- a/server/src/scripts/release-online-survey-app.sh +++ b/server/src/scripts/release-online-survey-app.sh @@ -46,7 +46,7 @@ mkdir -p $RELEASE_DIRECTORY/assets/locations mkdir -p $RELEASE_DIRECTORY/assets/media # Copy the form, location list, and media to the release directory -cp -r $FORM_DIRECTORY $RELEASE_DIRECTORY/assets/form +cp -r $FORM_DIRECTORY/*.html $RELEASE_DIRECTORY/assets/form/ cp $LOCATION_LIST_PATH $RELEASE_DIRECTORY/assets/ cp -r $LOCATION_LISTS_DIRECTORY $RELEASE_DIRECTORY/assets/ cp -r $MEDIA_DIRECTORY $RELEASE_DIRECTORY/assets/ From aaf3809a6cc0fde80f63f7adf1280bed17e7a13c Mon Sep 17 00:00:00 2001 From: esurface Date: Thu, 30 May 2024 18:52:49 -0400 Subject: [PATCH 05/42] Add server-side APIs to create Case, Event and Form --- server/src/case-api.js | 178 ++++++++++++++++++++++++++++++- server/src/classes/case.class.js | 91 ++++++++++++++++ server/src/express-app.js | 17 ++- server/src/online-survey.js | 12 +-- 4 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 server/src/classes/case.class.js diff --git a/server/src/case-api.js b/server/src/case-api.js index 3aee6e6cd4..c86bf2ac5e 100644 --- a/server/src/case-api.js +++ b/server/src/case-api.js @@ -1,6 +1,146 @@ const DB = require('./db.js') -const clog = require('tangy-log').clog const log = require('tangy-log').log +const path = require('path') +const fs = require('fs') +const { v4: uuidV4 } = require('uuid') + +const {Case: Case, CaseEvent: CaseEvent, EventForm: EventForm } = require('./classes/case.class.js') + +/* Return the contents of the case-definitions.json file or a sepcified */ +getCaseDefinitions = async (req, res) => { + const groupDir = `/tangerine/client/content/groups/${req.params.groupId}` + const caseType = 'case-definitions.json' + try { + const fileName = caseType.endsWith('.json') ? caseType : `${caseType}.json` + const filePath = path.join(groupDir, fileName) + if (fs.existsSync(filePath)) { + const fileContents = fs.readFileSync(filePath, "utf8") + return res.send(fileContents) + } else { + return res.status(500).send('Case definitions file not found') + } + } catch (err) { + log.error(`Error reading case definitions: ${err}`) + return res.status(500).send(err) + } +} + +getCaseDefinition = async (req, res) => { + const groupId = req.params.groupId + const caseDefinitionId = req.params.caseDefinitionId + try { + let caseDefinition = _getCaseDefinition(groupId, caseDefinitionId) + return res.send(caseDefinition) + } catch (err) { + log.error(`Error reading case definition file from ${caseType}`) + return res.status(500).send(err) + } +} + +_getCaseDefinition = (groupId, caseDefinitionId) => { + let caseDefinition = {}; + + const groupDir = `/tangerine/client/content/groups/${groupId}` + const fileName = caseDefinitionId.endsWith('.json') ? caseDefinitionId : `${caseDefinitionId}.json` + const filePath = path.join(groupDir, fileName) + if (fs.existsSync(filePath)) { + const caseDefinitionContent = fs.readFileSync(filePath, "utf8") + caseDefinition = JSON.parse(caseDefinitionContent) + } + + return caseDefinition; +} + +createCase = async (req, res) => { + let groupId = req.params.groupId + let caseDefinitionId = req.params.caseDefinitionId + + try { + const caseDefinition = _getCaseDefinition(groupId, caseDefinitionId) + let caseDoc = new Case(groupId, caseDefinitionId, caseDefinition) + + if (Object.keys(req.body).length > 0) { + const inputs = req.body + caseDoc.addInputs(inputs) + } + + const db = new DB(groupId) + await db.put(caseDoc); + res.send(caseDoc._id); + } catch (err) { + res.status(500).send(err) + } +} + +createCaseEvent = async (req, res) => { + let groupId = req.params.groupId + let caseId = req.params.caseId + let caseDefinitionId = req.params.caseDefinitionId + let caseEventDefinitionId = req.params.caseEventDefinitionId + + const caseDefinition = _getCaseDefinition(groupId, caseDefinitionId) + let caseEventDefinition = caseDefinition.eventDefinitions.find((e) => e.id === caseEventDefinitionId) + let caseEvent = new CaseEvent(groupId, caseEventDefinition) + + let caseDoc; + try { + const db = new DB(groupId) + caseDoc = await db.get(caseId); + caseDoc.events.push(caseEvent); + await db.put(caseDoc); + res.send(caseEvent._id); + } catch (err) { + res.status(500).send + } +} + +createEventForm = async (req, res) => { + let groupId = req.params.groupId + let caseId = req.params.caseId + let caseEventId = req.params.caseEventId + let eventFormDefinitionId = req.params.eventFormDefinitionId + + const caseDefinition = _getCaseDefinition(groupId, caseDefinitionId) + let eventFormDefinition; + for (const eventDefinition of caseDefinition.eventDefinitions) { + eventFormDefinition = eventDefinition.eventFormDefinitions.find((f) => f.id === eventFormDefinitionId) + if (eventFormDefinition) break; + } + + let eventForm = new EventForm(groupId, caseId, caseEventId, eventFormDefinition) + + try { + const db = new DB(groupId) + caseDoc = await db.get(caseId); + let caseEvent = caseDoc.events.find((e) => e.id === caseEventId); + caseEvent.eventForms.push(eventForm); + await db.put(caseDoc); + res.send(eventForm._id); + } catch (err) { + res.status(500).send + } +} + +createParticipant = async (req, res) => { + const groupId = req.params.groupId + const caseId = req.params.caseId + const caseRoleId = req.params.caseRoleId + + const caseDefinition = _getCaseDefinition(groupId, caseDefinitionId) + const caseRole = caseDefinition.caseRoles.find((r) => r.id === caseRoleId) + + let participant = new Participant(groupId, caseId, caseRole) + + try { + const db = new DB(groupId) + const caseDoc = await db.get(caseId); + caseDoc.participant.push(participant); + await db.put(caseDoc); + res.send(eventForm._id); + } catch (err) { + res.status(500).send + } +} getEventFormData = async (req, res) => { const groupDb = new DB(req.params.groupId) @@ -24,6 +164,42 @@ getEventFormData = async (req, res) => { res.send(data) } +getCaseEventFormSurveyLinks = async (req, res) => { + const caseId = req.params.caseId + const groupDb = new DB(req.params.groupId) + + let data = [] + try { + const results = await groupDb.get(caseId) + if (results.rows.length > 0) { + const caseDoc = results.rows[0].doc + const caseDefinition = _getCaseDefinition(groupId, caseDoc.caseDefinitionId) + for (let event of doc.events) { + let eventForm = event.eventForms.find((f) => f.id === req.params.eventFormId); + if (eventForm) { + let formId; + for (let eventDefinition of caseDefinition.eventDefinitions) { + formId = eventDefinition.find((e) => e.id === eventForm.eventFormDefinitionId).formId + if (formId) break; + } + } + const url = `http://localhost/releases/prod/online-survey-apps/group-344fabfe-f892-4a6d-a1da-58616949982f/${formId}/#/caseFormResponse/${caseId}/${eventForm.id}` + data.push(url) + break; + } + } + } + } catch (err) { + res.status(500).send(err); + } + res.send(data) +} + module.exports = { + getCaseDefinitions, + getCaseDefinition, + createCase, + createCaseEvent, + createEventForm, getEventFormData } \ No newline at end of file diff --git a/server/src/classes/case.class.js b/server/src/classes/case.class.js new file mode 100644 index 0000000000..2360c65e79 --- /dev/null +++ b/server/src/classes/case.class.js @@ -0,0 +1,91 @@ +const uuidV4 = require('uuid').v4 + +class Case { + constructor(groupId, caseDefinitionId, caseDefinition) { + this._id = uuidV4(); + this.type = 'case' + this.collection = 'TangyFormResponse' + this.caseDefinitionId = caseDefinitionId + this.archived = false + this.groupId = groupId + this.form = { id: caseDefinition.formId } + this.items = [{ inputs: [], }], + this.participants = [] + this.disabledEventDefinitionIds = [] + this.notifications = [] + + for (let eventDefinition of caseDefinition.eventDefinitions) { + if (eventDefinition.required) { + this.events.push(new CaseEvent(this._id, eventDefinition)) + } + } + } + + addInputs(inputs) { + for (let key in inputs) { + const input = { + name: key, + value: inputs[key] + } + // insert an unkeyed json object into the inputs array + this.items[0].inputs.push(input) + } + } +} + +class CaseEvent { + constructor(caseId, eventDefinition) { + this.id = uuidV4(); + this.caseId = caseId + this.name = '' + this.caseEventDefinitionId = eventDefinition.id + this.complete = false + this.inactive = false + this.eventForms = [] + + for (const eventForm of eventDefinition.eventFormDefinitions) { + if (eventForm.required) { + this.eventForms.push(new EventForm(caseId, this._id, eventForm)) + } + } + } + + addData(data) { + this.data = data + } +} + +class EventForm { + constructor(caseId, caseEventId, eventFormDefinition) { + this.id = uuidV4() + this.caseId = caseId + this.caseEventId = caseEventId + this.eventFormDefinitionId = eventFormDefinition.id + this.required = eventFormDefinition.required + this.participantId = '' + this.complete = false + this.inactive = false + } + + addData(data) { + this.data = data + } +} + +class Participant { + constructor(participantDefinition) { + this.id = uuidV4() + this.caseRoleId = participantDefinition.id + this.name = '' + this.inactive = false; + } + + addData(data) { + this.data = data + } +} + +exports.Case = Case +exports.CaseEvent = CaseEvent +exports.EventForm = EventForm +exports.Participant = Participant \ No newline at end of file diff --git a/server/src/express-app.js b/server/src/express-app.js index f0a41ef34e..d6551be8c3 100644 --- a/server/src/express-app.js +++ b/server/src/express-app.js @@ -47,7 +47,14 @@ const {registerUser, getUserByUsername, isUserSuperAdmin, isUserAnAdminUser, ge getAllUsers, checkIfUserExistByUsername, findOneUserByUsername, findMyUser, updateUser, restoreUser, updateMyUser} = require('./users'); const {login: surveyLogin, getResponse: getSurveyResponse, saveResponse: saveSurveyResponse, publishSurvey, unpublishSurvey} = require('./online-survey') -const { getEventFormData } = require('./case-api') +const { + getCaseDefinitions, + getCaseDefinition, + createCase, + createCaseEvent, + createEventForm, + getEventFormData +} = require('./case-api') log.info('heartbeat') setInterval(() => log.info('heartbeat'), 5*60*1000) const cookieParser = require('cookie-parser'); @@ -184,6 +191,12 @@ app.get('/users/groupPermissionsByGroupName/:groupName', isAuthenticated, getUse * Case API routes */ +app.get('/case/getCaseDefinitions/:groupId', getCaseDefinitions); +app.get('/case/getCaseDefinition/:groupId/:caseDefinitionId', getCaseDefinition); +app.post('/case/createCase/:groupId/:caseDefinitionId', createCase); +app.post('/case/createCaseEvent/:groupId/:caseId/:caseEventDefinitionId', createCaseEvent); +app.post('/case/createEventForm/:groupId/:caseId/:caseEventId/:caseEventFormDefinitionId', createEventForm); + app.get('/case/getEventFormData/:groupId/:eventFormId', getEventFormData); /** @@ -196,7 +209,7 @@ app.post('/onlineSurvey/saveResponse/:groupId/:formId', hasSurveyUploadKey, save app.post('/onlineSurvey/login/:groupId/:accessCode', surveyLogin); app.get('/onlineSurvey/getResponse/:groupId/:formResponseId', /* hasSurveyUploadKey,*/ getSurveyResponse); -app.post('/onlineSurvey/saveResponse/:groupId/:caseEventFormId/:formResponseId', /* hasSurveyUploadKey,*/ saveSurveyResponse); +app.post('/onlineSurvey/saveResponse/:groupId/:eventFormId/:formResponseId', /* hasSurveyUploadKey,*/ saveSurveyResponse); /* * More API */ diff --git a/server/src/online-survey.js b/server/src/online-survey.js index 26bdd66399..58e92f0fc4 100644 --- a/server/src/online-survey.js +++ b/server/src/online-survey.js @@ -51,30 +51,30 @@ const getResponse = async (req, res) => { const saveResponse = async (req, res) => { try { - const { groupId, formId, caseEventFormId } = req.params; + const { groupId, formId, eventFormId } = req.params; - console.log('saveResponse', groupId, formId, caseEventFormId); + console.log('saveResponse', groupId, formId, eventFormId); const db = new DB(groupId); const data = req.body; data.formId = formId; - if (caseEventFormId) { + if (eventFormId) { try { - const results = await db.query("eventForms/eventForms", {key: caseEventFormId, include_docs: true}); + const results = await db.query("eventForms/eventForms", {key: eventFormId, include_docs: true}); if (results.rows.length > 0) { const caseDoc = results.rows[0].doc; console.log('caseId', caseDoc._id) for (let event of caseDoc.events) { - let eventForm = event.eventForms.find((f) => f.id === caseEventFormId); + let eventForm = event.eventForms.find((f) => f.id === eventFormId); if (eventForm) { console.log('eventForm', eventForm.id); data.caseId = caseDoc._id; data.caseEventId = event.id; - data.caseEventFormId = eventForm.id; + data.eventFormId = eventForm.id; eventForm.formResponseId = data._id; eventForm.complete = true From 9752ad58b80fcae1bf37e5af62bc43bc5c81b9a1 Mon Sep 17 00:00:00 2001 From: esurface Date: Fri, 31 May 2024 13:33:37 -0400 Subject: [PATCH 06/42] Fix unfinished case-api function --- server/src/case-api.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/server/src/case-api.js b/server/src/case-api.js index c86bf2ac5e..08be95ae47 100644 --- a/server/src/case-api.js +++ b/server/src/case-api.js @@ -173,20 +173,19 @@ getCaseEventFormSurveyLinks = async (req, res) => { const results = await groupDb.get(caseId) if (results.rows.length > 0) { const caseDoc = results.rows[0].doc - const caseDefinition = _getCaseDefinition(groupId, caseDoc.caseDefinitionId) + const caseDefinition = _getCaseDefinition(groupId, caseDoc.caseDefinitionId) for (let event of doc.events) { let eventForm = event.eventForms.find((f) => f.id === req.params.eventFormId); if (eventForm) { let formId; for (let eventDefinition of caseDefinition.eventDefinitions) { - formId = eventDefinition.find((e) => e.id === eventForm.eventFormDefinitionId).formId - if (formId) break; - } + formId = eventDefinition.find((e) => e.id === eventForm.eventFormDefinitionId).formId + if (formId) break; } - const url = `http://localhost/releases/prod/online-survey-apps/group-344fabfe-f892-4a6d-a1da-58616949982f/${formId}/#/caseFormResponse/${caseId}/${eventForm.id}` - data.push(url) - break; } + const url = `http://localhost/releases/prod/online-survey-apps/group-344fabfe-f892-4a6d-a1da-58616949982f/${formId}/#/caseFormResponse/${caseId}/${eventForm.id}` + data.push(url) + break; } } } catch (err) { From a92d271d43e7d9cd2b9c43be24bb13f3a45177b3 Mon Sep 17 00:00:00 2001 From: esurface Date: Fri, 31 May 2024 16:12:51 -0400 Subject: [PATCH 07/42] Fix bugs in case-api and add user-profile create api --- server/src/case-api.js | 1 + server/src/classes/case.class.js | 6 +++-- server/src/classes/user-profile.class.js | 27 ++++++++++++++++++++++ server/src/express-app.js | 8 +++++++ server/src/user-profile.js | 29 ++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 server/src/classes/user-profile.class.js create mode 100644 server/src/user-profile.js diff --git a/server/src/case-api.js b/server/src/case-api.js index 08be95ae47..7432b9fcb5 100644 --- a/server/src/case-api.js +++ b/server/src/case-api.js @@ -68,6 +68,7 @@ createCase = async (req, res) => { await db.put(caseDoc); res.send(caseDoc._id); } catch (err) { + log.error(`Error creating case: ${err}`) res.status(500).send(err) } } diff --git a/server/src/classes/case.class.js b/server/src/classes/case.class.js index 2360c65e79..13be0959e6 100644 --- a/server/src/classes/case.class.js +++ b/server/src/classes/case.class.js @@ -8,11 +8,13 @@ class Case { this.caseDefinitionId = caseDefinitionId this.archived = false this.groupId = groupId + this.location = {} this.form = { id: caseDefinition.formId } - this.items = [{ inputs: [], }], + this.items = [{ inputs: [] }], this.participants = [] this.disabledEventDefinitionIds = [] this.notifications = [] + this.events = [] for (let eventDefinition of caseDefinition.eventDefinitions) { if (eventDefinition.required) { @@ -22,12 +24,12 @@ class Case { } addInputs(inputs) { + // transform key, value pairs to 'name' : key, 'value': value pairs for (let key in inputs) { const input = { name: key, value: inputs[key] } - // insert an unkeyed json object into the inputs array this.items[0].inputs.push(input) } } diff --git a/server/src/classes/user-profile.class.js b/server/src/classes/user-profile.class.js new file mode 100644 index 0000000000..698815f0c9 --- /dev/null +++ b/server/src/classes/user-profile.class.js @@ -0,0 +1,27 @@ +const uuidV4 = require('uuid').v4 + +class UserProfile { + constructor(groupId) { + this._id = uuidV4(); + this.type = 'response' + this.collection = 'TangyFormResponse' + this.groupId = groupId + this.form = { id: "user-profile" } + this.items = [{ inputs: [] }] + this.location = {}; + } + + addInputs(inputs) { + for (let key in inputs) { + const input = { + name: key, + value: inputs[key] + } + // insert an unkeyed json object into the inputs array + this.items[0].inputs.push(input) + } + } +} + + +exports.UserProfile = UserProfile \ No newline at end of file diff --git a/server/src/express-app.js b/server/src/express-app.js index d6551be8c3..f877669ed5 100644 --- a/server/src/express-app.js +++ b/server/src/express-app.js @@ -20,6 +20,7 @@ const pouchRepStream = require('pouchdb-replication-stream'); PouchDB.plugin(require('pouchdb-find')); PouchDB.plugin(pouchRepStream.plugin); PouchDB.adapter('writableStream', pouchRepStream.adapters.writableStream); +const {DB} = require('./db') const pako = require('pako') const compression = require('compression') const chalk = require('chalk'); @@ -55,6 +56,7 @@ const { createEventForm, getEventFormData } = require('./case-api') +const { createUserProfile } = require('./user-profile') log.info('heartbeat') setInterval(() => log.info('heartbeat'), 5*60*1000) const cookieParser = require('cookie-parser'); @@ -187,6 +189,12 @@ app.get('/users/groupPermissionsByGroupName/:groupName', isAuthenticated, getUse app.get('/configuration/archiveToDisk', isAuthenticated, archiveToDiskConfig); app.get('/configuration/passwordPolicyConfig', isAuthenticated, passwordPolicyConfig); +/** + * User Profile API + */ + +app.post('/userProfile/create/:groupId', createUserProfile); + /** * Case API routes */ diff --git a/server/src/user-profile.js b/server/src/user-profile.js new file mode 100644 index 0000000000..9f0068b539 --- /dev/null +++ b/server/src/user-profile.js @@ -0,0 +1,29 @@ +const DB = require('./db.js') +const log = require('tangy-log').log +const path = require('path') +const fs = require('fs') + +const { UserProfile: UserProfile } = require('./classes/user-profile.class.js') + +createUserProfile = async (req, res) => { + let groupId = req.params.groupId + + try { + let userProfile = new UserProfile(groupId) + + if (Object.keys(req.body).length > 0) { + const inputs = req.body + userProfile.addInputs(inputs) + } + + const db = new DB(groupId) + await db.put(userProfile); + res.send(userProfile._id); + } catch (err) { + res.status(500).send(err) + } +} + +module.exports = { + createUserProfile +} \ No newline at end of file From 69fce194df6664d6a9af5b75ea725dfd5f6ca07a Mon Sep 17 00:00:00 2001 From: esurface Date: Fri, 31 May 2024 17:29:08 -0400 Subject: [PATCH 08/42] Add app config 'requireAccessCode' for users to lock access to online surveys --- .../release-online-survey.component.html | 43 ++++++++++++++----- .../release-online-survey.component.ts | 4 +- .../src/app/groups/services/groups.service.ts | 7 +-- .../core/auth/_guards/login-guard.service.ts | 17 +++++--- online-survey-app/src/assets/app-config.json | 3 +- server/src/express-app.js | 5 ++- server/src/online-survey.js | 3 ++ server/src/releases.js | 13 +++++- .../src/scripts/release-online-survey-app.sh | 10 ++++- 9 files changed, 79 insertions(+), 26 deletions(-) diff --git a/editor/src/app/groups/release-online-survey/release-online-survey.component.html b/editor/src/app/groups/release-online-survey/release-online-survey.component.html index 06fa3ad30a..9820cc3daa 100644 --- a/editor/src/app/groups/release-online-survey/release-online-survey.component.html +++ b/editor/src/app/groups/release-online-survey/release-online-survey.component.html @@ -3,15 +3,7 @@

Instructions:

- In the Unpublished Surveys section, click the - - button to "Publish" an online survey. It will then be listed in the Published Surveys section. -
- -
- In the Published Surveys section, use the link + In the Published Surveys section, use the link @@ -21,14 +13,40 @@

Instructions:

unpublished button to "Un-publish" an online survey. + The + button appears for surveys that require a Device User and Access Code.
+ +
+ In the Unpublish Surveys section, click the + + button to "Publish" an online survey. It will then be listed in the Published Surveys section. + To 'Publish' a survey and require a Device User to provide an Access Code, click the + button. +
+ +
+ +
+

{{'Published Surveys'|translate}}

{{index+1}} -       +    + + + {{form.updatedOn|date :'medium'}} @@ -49,9 +67,12 @@

{{'Unpublished Surveys'|translate}}

- + {{form.updatedOn|date :'medium'}} diff --git a/editor/src/app/groups/release-online-survey/release-online-survey.component.ts b/editor/src/app/groups/release-online-survey/release-online-survey.component.ts index b045a663b2..bc38a1a000 100644 --- a/editor/src/app/groups/release-online-survey/release-online-survey.component.ts +++ b/editor/src/app/groups/release-online-survey/release-online-survey.component.ts @@ -46,9 +46,9 @@ export class ReleaseOnlineSurveyComponent implements OnInit { this.publishedSurveys = surveyData.filter(e => e.published); this.unPublishedSurveys = surveyData.filter(e => !e.published); } - async publishSurvey(formId, appName) { + async publishSurvey(formId, appName, locked) { try { - await this.groupService.publishSurvey(this.groupId, formId, 'prod', appName); + await this.groupService.publishSurvey(this.groupId, formId, 'prod', appName, locked); await this.getForms(); this.errorHandler.handleError(_TRANSLATE('Survey Published Successfully.')); } catch (error) { diff --git a/editor/src/app/groups/services/groups.service.ts b/editor/src/app/groups/services/groups.service.ts index efb25b5809..6e3f4e0db0 100644 --- a/editor/src/app/groups/services/groups.service.ts +++ b/editor/src/app/groups/services/groups.service.ts @@ -398,10 +398,11 @@ export class GroupsService { } } - async publishSurvey(groupId, formId, releaseType = 'prod', appName) { + async publishSurvey(groupId, formId, releaseType = 'prod', appName, locked=false) { try { - const response = await this.httpClient.post(`/onlineSurvey/publish/${groupId}/${formId}`, {groupId, formId}, {observe: 'response'}).toPromise(); - await this.httpClient.get(`/editor/release-online-survey-app/${groupId}/${formId}/${releaseType}/${appName}/${response.body['uploadKey']}`).toPromise() + const response = await this.httpClient.post(`/onlineSurvey/publish/${groupId}/${formId}`, {groupId, formId, locked}, {observe: 'response'}).toPromise(); + const uploadKey = response.body['uploadKey'] + await this.httpClient.post(`/editor/release-online-survey-app/${groupId}/${formId}/${releaseType}/${appName}`, {uploadKey, locked}).toPromise() } catch (error) { this.errorHandler.handleError(_TRANSLATE('Could Not Contact Server.')); } diff --git a/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts index b9986cbdef..1f4ac789a3 100644 --- a/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts +++ b/online-survey-app/src/app/core/auth/_guards/login-guard.service.ts @@ -1,19 +1,26 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; import { AuthenticationService } from '../_services/authentication.service'; +import { AppConfigService } from 'src/app/shared/_services/app-config.service'; @Injectable() export class LoginGuard implements CanActivate { constructor( private router: Router, - private authenticationService: AuthenticationService + private authenticationService: AuthenticationService, + private appConfigService: AppConfigService ) { } async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - if (await this.authenticationService.isLoggedIn()) { - return true; + const config = await this.appConfigService.getAppConfig(); + if (config['requireAccessCode'] === 'true') { + if (await this.authenticationService.isLoggedIn()) { + return true; + } + this.router.navigate(['survey-login'], { queryParams: { returnUrl: state.url } }); + return false; } - this.router.navigate(['survey-login'], { queryParams: { returnUrl: state.url } }); - return false; + + return true; } } diff --git a/online-survey-app/src/assets/app-config.json b/online-survey-app/src/assets/app-config.json index 06c3866df4..58d107cffe 100644 --- a/online-survey-app/src/assets/app-config.json +++ b/online-survey-app/src/assets/app-config.json @@ -4,5 +4,6 @@ "groupId":"GROUP_ID", "languageDirection": "ltr", "languageCode": "en", - "appName":"APP_NAME" + "appName":"APP_NAME", + "requireAccessCode": "REQUIRE_ACCESS_CODE" } \ No newline at end of file diff --git a/server/src/express-app.js b/server/src/express-app.js index f877669ed5..9de24e7952 100644 --- a/server/src/express-app.js +++ b/server/src/express-app.js @@ -193,7 +193,7 @@ app.get('/users/groupPermissionsByGroupName/:groupName', isAuthenticated, getUse * User Profile API */ -app.post('/userProfile/create/:groupId', createUserProfile); +app.post('/userProfile/createUserProfile/:groupId', createUserProfile); /** * Case API routes @@ -321,8 +321,11 @@ app.post('/editor/release-apk/:group', isAuthenticated, releaseAPK) app.post('/editor/release-pwa/:group/', isAuthenticated, releasePWA) +// TODO @deprice: This route should be removed. app.use('/editor/release-online-survey-app/:groupId/:formId/:releaseType/:appName/:uploadKey/', isAuthenticated, releaseOnlineSurveyApp) +app.post('/editor/release-online-survey-app/:groupId/:formId/:releaseType/:appName/', isAuthenticated, releaseOnlineSurveyApp) + app.use('/editor/unrelease-online-survey-app/:groupId/:formId/:releaseType/', isAuthenticated, unreleaseOnlineSurveyApp) app.post('/editor/file/save', isAuthenticated, async function (req, res) { diff --git a/server/src/online-survey.js b/server/src/online-survey.js index 58e92f0fc4..6104647ffc 100644 --- a/server/src/online-survey.js +++ b/server/src/online-survey.js @@ -3,6 +3,7 @@ const GROUPS_DB = new DB('groups'); const { v4: uuidV4 } = require('uuid'); const createFormKey = () => uuidV4(); const { createAccessCodeJWT } = require('./auth-utils'); +const { log } = require('console'); const login = async (req, res) => { try { @@ -100,6 +101,7 @@ const publishSurvey = async (req, res) => { let surveyInfo = {} try { const { formId, groupId } = req.params; + const locked = req.body.locked || false; const data = (await GROUPS_DB.get(groupId)); data.onlineSurveys = data.onlineSurveys ? data.onlineSurveys : []; let surveysIndex = data.onlineSurveys.findIndex((e) => e.formId === formId); @@ -109,6 +111,7 @@ const publishSurvey = async (req, res) => { updatedOn: new Date(), updatedBy: req.user.name, uploadKey: createFormKey(), + locked: locked }; if (surveysIndex < 0) { data.onlineSurveys = [...data.onlineSurveys, surveyInfo]; diff --git a/server/src/releases.js b/server/src/releases.js index 744ab6441f..21f8b20acb 100644 --- a/server/src/releases.js +++ b/server/src/releases.js @@ -63,14 +63,23 @@ const releasePWA = async (req, res)=>{ const releaseOnlineSurveyApp = async(req, res) => { // @TODO Make sure user is member of group. + debugger; const groupId = sanitize(req.params.groupId) const formId = sanitize(req.params.formId) const releaseType = sanitize(req.params.releaseType) const appName = sanitize(req.params.appName) - const uploadKey = sanitize(req.params.uploadKey) + + let uploadKey; + if (req.params.uploadKey) { + uploadKey = sanitize(req.params.uploadKey) + } else { + uploadKey = sanitize(req.body.uploadKey) + } + debugger; + const requireAccessCode = req.body.locked ? req.body.locked : false try { - const cmd = `release-online-survey-app ${groupId} ${formId} ${releaseType} "${appName}" ${uploadKey} ` + const cmd = `release-online-survey-app ${groupId} ${formId} ${releaseType} "${appName}" ${uploadKey} ${requireAccessCode}` log.info(`RELEASING Online survey app: ${cmd}`) await exec(cmd) res.send({ statusCode: 200, data: 'ok' }) diff --git a/server/src/scripts/release-online-survey-app.sh b/server/src/scripts/release-online-survey-app.sh index 634393bd0a..b37034462d 100755 --- a/server/src/scripts/release-online-survey-app.sh +++ b/server/src/scripts/release-online-survey-app.sh @@ -5,13 +5,14 @@ FORM_ID="$2" RELEASE_TYPE="$3" APP_NAME=$(echo "$4" | sed "s/ /_/g") # sanitize spaces in app name UPLOAD_KEY="$5" +REQUIRE_ACCESS_CODE="$6" if [ "$2" = "--help" ] || [ "$GROUP_ID" = "" ] || [ "$FORM_ID" = "" ] || [ "$RELEASE_TYPE" = "" ]; then echo "" echo "RELEASE Online Survey App" echo "A command for releasing the Online Survey App for a specific form in a group." echo "" - echo "./release-online-survey-app.sh " + echo "./release-online-survey-app.sh [requireAccessCode]" echo "" echo "Release type is either qa or prod." echo "" @@ -54,10 +55,17 @@ cp /tangerine/translations/*.json $RELEASE_DIRECTORY/assets/ FORM_UPLOAD_URL="/onlineSurvey/saveResponse/$GROUP_ID/$FORM_ID" +if [[ $REQUIRE_ACCESS_CODE == 'true' ]]; then + REQUIRE_ACCESS_CODE="true" +else + REQUIRE_ACCESS_CODE="false" +fi + # NOTE: App Config does NOT come from the app-config.json file in the release directory sed -i -e "s#GROUP_ID#"$GROUP_ID"#g" $RELEASE_DIRECTORY/assets/app-config.json sed -i -e "s#FORM_UPLOAD_URL#"$FORM_UPLOAD_URL"#g" $RELEASE_DIRECTORY/assets/app-config.json sed -i -e "s#UPLOAD_KEY#"$UPLOAD_KEY"#g" $RELEASE_DIRECTORY/assets/app-config.json sed -i -e "s#APP_NAME#"$APP_NAME"#g" $RELEASE_DIRECTORY/assets/app-config.json +sed -i -e "s#REQUIRE_ACCESS_CODE#"$REQUIRE_ACCESS_CODE"#g" $RELEASE_DIRECTORY/assets/app-config.json echo "Release with UUID of $UUID to $RELEASE_DIRECTORY with Build ID of $BUILD_ID, channel of $RELEASE_TYPE" From d85217891c9af59564e84fbe85f1c92afcc38bdd Mon Sep 17 00:00:00 2001 From: esurface Date: Mon, 3 Jun 2024 15:17:33 -0400 Subject: [PATCH 09/42] Reorder span items in release online survey component --- .../release-online-survey.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/editor/src/app/groups/release-online-survey/release-online-survey.component.html b/editor/src/app/groups/release-online-survey/release-online-survey.component.html index 9820cc3daa..3f98252b5f 100644 --- a/editor/src/app/groups/release-online-survey/release-online-survey.component.html +++ b/editor/src/app/groups/release-online-survey/release-online-survey.component.html @@ -65,7 +65,7 @@

{{'Unpublished Surveys'|translate}}

{{index+1}}       - + {{form.updatedOn|date :'medium'}} - - {{form.updatedOn|date :'medium'}}
From 1ef7c00d35638a1eee6d497bfbd64b25803232c0 Mon Sep 17 00:00:00 2001 From: esurface Date: Mon, 3 Jun 2024 15:17:58 -0400 Subject: [PATCH 10/42] Include custom-scripts in online-survey releases --- online-survey-app/src/index.html | 2 ++ server/src/scripts/release-online-survey-app.sh | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/online-survey-app/src/index.html b/online-survey-app/src/index.html index 8b9e161294..64c8e5db67 100644 --- a/online-survey-app/src/index.html +++ b/online-survey-app/src/index.html @@ -19,4 +19,6 @@ } + + diff --git a/server/src/scripts/release-online-survey-app.sh b/server/src/scripts/release-online-survey-app.sh index b37034462d..2cce11623b 100755 --- a/server/src/scripts/release-online-survey-app.sh +++ b/server/src/scripts/release-online-survey-app.sh @@ -38,6 +38,8 @@ FORM_DIRECTORY="$FORM_CLIENT_DIRECTORY/$FORM_ID" LOCATION_LIST_PATH="$FORM_CLIENT_DIRECTORY/location-list.json" LOCATION_LISTS_DIRECTORY="$FORM_CLIENT_DIRECTORY/locations" MEDIA_DIRECTORY="$FORM_CLIENT_DIRECTORY/media" +CUSTOM_SCRIPTS="$FORM_CLIENT_DIRECTORY/custom-scripts.js" +CUSTOM_SCRIPTS_MAP="$FORM_CLIENT_DIRECTORY/custom-scripts.js.map" # Set up the release dir from the dist cp -r /tangerine/online-survey-app/dist/online-survey-app/ $RELEASE_DIRECTORY @@ -52,6 +54,8 @@ cp $LOCATION_LIST_PATH $RELEASE_DIRECTORY/assets/ cp -r $LOCATION_LISTS_DIRECTORY $RELEASE_DIRECTORY/assets/ cp -r $MEDIA_DIRECTORY $RELEASE_DIRECTORY/assets/ cp /tangerine/translations/*.json $RELEASE_DIRECTORY/assets/ +cp $CUSTOM_SCRIPTS $RELEASE_DIRECTORY/assets/ +cp $CUSTOM_SCRIPTS_MAP $RELEASE_DIRECTORY/assets/ FORM_UPLOAD_URL="/onlineSurvey/saveResponse/$GROUP_ID/$FORM_ID" From c4b6c68c492d2d4124ad5c94e8e410bc10d87f79 Mon Sep 17 00:00:00 2001 From: esurface Date: Mon, 3 Jun 2024 16:07:41 -0400 Subject: [PATCH 11/42] Online Survey - save case variables to local storage --- .../src/app/app-routing.module.ts | 2 +- .../tangy-forms-player.component.ts | 37 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/online-survey-app/src/app/app-routing.module.ts b/online-survey-app/src/app/app-routing.module.ts index 7370b484d0..d08958145b 100644 --- a/online-survey-app/src/app/app-routing.module.ts +++ b/online-survey-app/src/app/app-routing.module.ts @@ -12,7 +12,7 @@ const routes: Routes = [ { path: 'form-submitted-success', component: FormSubmittedSuccessComponent, canActivate: [LoginGuard] }, { path: 'form/:formId', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, { path: 'form/option/:formId/:option', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, - { path: 'caseFormResponse/:caseEventFormId', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, + { path: 'case/event/form/:case/:event/:form', component: TangyFormsPlayerComponent, canActivate: [LoginGuard] }, ]; @NgModule({ diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts b/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts index b3d3a7eccf..4b2183bb69 100644 --- a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts +++ b/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts @@ -13,14 +13,18 @@ export class TangyFormsPlayerComponent implements OnInit { formId: string; formResponseId: string; - caseEventFormId: string; + caseId: string; + caseEventId: string; + eventFormId: string; constructor(private route: ActivatedRoute, private formsService: FormsService, private router: Router, private httpClient:HttpClient ) { this.router.events.subscribe(async (event) => { this.formId = this.route.snapshot.paramMap.get('formId'); this.formResponseId = this.route.snapshot.paramMap.get('formResponseId'); - this.caseEventFormId = this.route.snapshot.paramMap.get('caseEventFormId'); + this.caseId = this.route.snapshot.paramMap.get('caseId'); + this.caseEventId = this.route.snapshot.paramMap.get('caseEventId'); + this.eventFormId = this.route.snapshot.paramMap.get('eventFormId'); }); } @@ -29,22 +33,37 @@ export class TangyFormsPlayerComponent implements OnInit { this.container.nativeElement.innerHTML = data; let tangyForm = this.container.nativeElement.querySelector('tangy-form'); - if (this.caseEventFormId) { + if (this.caseId) { try { - const eventForm = await this.formsService.getEventFormData(this.caseEventFormId); - if (eventForm && eventForm.formResponseId) { - tangyForm.response = await this.formsService.getFormResponse(eventForm.formResponseId); + const caseDoc = await this.formsService.getFormResponse(this.caseId); + if (caseDoc) { + let inputs = caseDoc.items[0].inputs; + if (inputs.length > 0) { + window.localStorage.setItem('caseVariables', JSON.stringify(inputs)); + } } } catch (error) { - //pass + console.log('Error loading case variables: ' + error) + } + + if (this.eventFormId) { + try { + // Attempt to load the form response for the event form + const eventForm = await this.formsService.getEventFormData(this.eventFormId); + if (eventForm && eventForm.formResponseId) { + tangyForm.response = await this.formsService.getFormResponse(eventForm.formResponseId); + } + } catch (error) { + //pass + } } } tangyForm.addEventListener('after-submit', async (event) => { event.preventDefault(); try { - if (this.caseEventFormId) { - if (await this.formsService.uploadFormResponseForCase(event.target.response, this.caseEventFormId)) { + if (this.eventFormId) { + if (await this.formsService.uploadFormResponseForCase(event.target.response, this.eventFormId)) { this.router.navigate(['/form-submitted-success']); } else { alert('Form could not be submitted. Please retry'); From 4d8c5b6035bc461a35e174f8d1a30c3c745ab323 Mon Sep 17 00:00:00 2001 From: esurface Date: Tue, 4 Jun 2024 10:25:06 -0400 Subject: [PATCH 12/42] Add case module to online-survey-app --- develop.sh | 1 - online-survey-app/package.json | 2 + online-survey-app/src/app/app-context.enum.ts | 4 + .../src/app/app-routing.module.ts | 2 +- online-survey-app/src/app/app.component.ts | 6 +- online-survey-app/src/app/app.module.ts | 8 +- online-survey-app/src/app/case/case.module.ts | 25 + .../app/case/classes/case-definition.class.ts | 48 + .../classes/case-event-definition.class.ts | 31 + .../src/app/case/classes/case-event.class.ts | 25 + .../case/classes/case-participant.class.ts | 7 + .../src/app/case/classes/case-role.class.ts | 7 + .../src/app/case/classes/case.class.ts | 29 + .../classes/event-form-definition.class.ts | 21 + .../src/app/case/classes/event-form.class.ts | 17 + .../app/case/classes/notification.class.ts | 29 + .../src/app/case/classes/query.class.ts | 46 + .../services/case-definitions.service.spec.ts | 13 + .../case/services/case-definitions.service.ts | 28 + .../case/services/case-event-info.class.ts | 12 + .../app/case/services/case.service.spec.ts | 292 +++++ .../src/app/case/services/case.service.ts | 1101 +++++++++++++++++ .../app/case/services/cases.service.spec.ts | 264 ++++ .../src/app/case/services/cases.service.ts | 46 + .../shared/_services/app-config.service.ts | 29 + .../app/shared/classes/user-database.class.ts | 130 +- .../tangy-forms/classes/form-info.class.ts | 63 + .../tangy-forms/classes/form-version.class.ts | 5 + .../src/app/tangy-forms/safe-url.pipe.ts | 13 + .../tangy-forms/tangy-form-response.class.ts | 43 + .../src/app/tangy-forms/tangy-form-service.ts | 41 + .../src/app/tangy-forms/tangy-form.service.ts | 72 ++ .../tangy-forms/tangy-forms-info-service.ts | 38 + .../tangy-forms-player.component.css | 0 .../tangy-forms-player.component.html | 0 .../tangy-forms-player.component.spec.ts | 0 .../tangy-forms-player.component.ts | 49 +- .../tangy-forms/tangy-forms-routing.module.ts | 15 + .../src/app/tangy-forms/tangy-forms.module.ts | 32 + .../src/scripts/release-online-survey-app.sh | 23 +- 40 files changed, 2503 insertions(+), 114 deletions(-) create mode 100644 online-survey-app/src/app/app-context.enum.ts create mode 100644 online-survey-app/src/app/case/case.module.ts create mode 100644 online-survey-app/src/app/case/classes/case-definition.class.ts create mode 100644 online-survey-app/src/app/case/classes/case-event-definition.class.ts create mode 100644 online-survey-app/src/app/case/classes/case-event.class.ts create mode 100644 online-survey-app/src/app/case/classes/case-participant.class.ts create mode 100644 online-survey-app/src/app/case/classes/case-role.class.ts create mode 100644 online-survey-app/src/app/case/classes/case.class.ts create mode 100644 online-survey-app/src/app/case/classes/event-form-definition.class.ts create mode 100644 online-survey-app/src/app/case/classes/event-form.class.ts create mode 100644 online-survey-app/src/app/case/classes/notification.class.ts create mode 100644 online-survey-app/src/app/case/classes/query.class.ts create mode 100644 online-survey-app/src/app/case/services/case-definitions.service.spec.ts create mode 100644 online-survey-app/src/app/case/services/case-definitions.service.ts create mode 100644 online-survey-app/src/app/case/services/case-event-info.class.ts create mode 100644 online-survey-app/src/app/case/services/case.service.spec.ts create mode 100644 online-survey-app/src/app/case/services/case.service.ts create mode 100644 online-survey-app/src/app/case/services/cases.service.spec.ts create mode 100644 online-survey-app/src/app/case/services/cases.service.ts create mode 100644 online-survey-app/src/app/tangy-forms/classes/form-info.class.ts create mode 100644 online-survey-app/src/app/tangy-forms/classes/form-version.class.ts create mode 100755 online-survey-app/src/app/tangy-forms/safe-url.pipe.ts create mode 100644 online-survey-app/src/app/tangy-forms/tangy-form-response.class.ts create mode 100644 online-survey-app/src/app/tangy-forms/tangy-form-service.ts create mode 100644 online-survey-app/src/app/tangy-forms/tangy-form.service.ts create mode 100644 online-survey-app/src/app/tangy-forms/tangy-forms-info-service.ts rename online-survey-app/src/app/{ => tangy-forms}/tangy-forms-player/tangy-forms-player.component.css (100%) rename online-survey-app/src/app/{ => tangy-forms}/tangy-forms-player/tangy-forms-player.component.html (100%) rename online-survey-app/src/app/{ => tangy-forms}/tangy-forms-player/tangy-forms-player.component.spec.ts (100%) rename online-survey-app/src/app/{ => tangy-forms}/tangy-forms-player/tangy-forms-player.component.ts (65%) create mode 100755 online-survey-app/src/app/tangy-forms/tangy-forms-routing.module.ts create mode 100755 online-survey-app/src/app/tangy-forms/tangy-forms.module.ts diff --git a/develop.sh b/develop.sh index 7182a9f976..636a36b88b 100755 --- a/develop.sh +++ b/develop.sh @@ -75,7 +75,6 @@ fi if echo "$T_MODULES" | grep mysql; then ./mysql-create-dirs.sh -fi if echo "$T_USE_MYSQL_CONTAINER" | grep "true"; then ./mysql-start-container.sh diff --git a/online-survey-app/package.json b/online-survey-app/package.json index feb1c86b68..364098872d 100644 --- a/online-survey-app/package.json +++ b/online-survey-app/package.json @@ -24,6 +24,8 @@ "@ngx-translate/core": "^11.0.1", "@ngx-translate/http-loader": "^4.0.0", "@webcomponents/webcomponentsjs": "^2.4.4", + "axios": "^0.21.4", + "fast-json-patch": "^3.1.1", "jwt-decode": "^4.0.0", "material-design-icons-iconfont": "^6.1.0", "redux": "^4.0.5", diff --git a/online-survey-app/src/app/app-context.enum.ts b/online-survey-app/src/app/app-context.enum.ts new file mode 100644 index 0000000000..e627f5e55d --- /dev/null +++ b/online-survey-app/src/app/app-context.enum.ts @@ -0,0 +1,4 @@ +export enum AppContext { + Editor = 'EDITOR', + Client = 'CLIENT' +} diff --git a/online-survey-app/src/app/app-routing.module.ts b/online-survey-app/src/app/app-routing.module.ts index d08958145b..4d774915d8 100644 --- a/online-survey-app/src/app/app-routing.module.ts +++ b/online-survey-app/src/app/app-routing.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { FormSubmittedSuccessComponent } from './form-submitted-success/form-submitted-success.component'; import { FormsListComponent } from './forms-list/forms-list.component'; -import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; +import { TangyFormsPlayerComponent } from './tangy-forms/tangy-forms-player/tangy-forms-player.component'; import { LoginGuard } from './core/auth/_guards/login-guard.service'; import { SurveyLoginComponent } from './core/auth/_components/survey-login/survey-login.component'; diff --git a/online-survey-app/src/app/app.component.ts b/online-survey-app/src/app/app.component.ts index 3d0d582a40..3d856bcc62 100644 --- a/online-survey-app/src/app/app.component.ts +++ b/online-survey-app/src/app/app.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { AppConfigService } from './shared/_services/app-config.service'; +import { CaseService } from './case/services/case.service'; @Component({ selector: 'app-root', @@ -9,7 +10,9 @@ import { AppConfigService } from './shared/_services/app-config.service'; export class AppComponent implements OnInit{ languageDirection: string; appName: string; - constructor(private appConfigService: AppConfigService){ + window: any; + + constructor(private appConfigService: AppConfigService, private caseService: CaseService){ } async ngOnInit(): Promise{ @@ -17,6 +20,7 @@ export class AppComponent implements OnInit{ const appConfig = await this.appConfigService.getAppConfig(); this.appName = appConfig.appName; this.languageDirection = appConfig.languageDirection; + } catch (error) { this.appName = ''; this.languageDirection = 'ltr'; diff --git a/online-survey-app/src/app/app.module.ts b/online-survey-app/src/app/app.module.ts index 8080a7af5b..2ed7fa567b 100644 --- a/online-survey-app/src/app/app.module.ts +++ b/online-survey-app/src/app/app.module.ts @@ -13,17 +13,17 @@ import {MatListModule} from '@angular/material/list'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { TangySvgLogoComponent } from './shared/tangy-svg-logo/tangy-svg-logo.component'; -import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; +import { TangyFormsModule } from './tangy-forms/tangy-forms.module'; import { HttpClientModule } from '@angular/common/http'; import { FormsListComponent } from './forms-list/forms-list.component'; import { FormSubmittedSuccessComponent } from './form-submitted-success/form-submitted-success.component'; import { TangyErrorHandler } from './shared/_services/tangy-error-handler.service'; +import { CaseModule } from './case/case.module'; @NgModule({ declarations: [ AppComponent, TangySvgLogoComponent, - TangyFormsPlayerComponent, FormsListComponent, FormSubmittedSuccessComponent ], @@ -32,6 +32,7 @@ import { TangyErrorHandler } from './shared/_services/tangy-error-handler.servic AppRoutingModule, BrowserModule, BrowserAnimationsModule, + CaseModule, MatToolbarModule, MatMenuModule, MatIconModule, @@ -39,7 +40,8 @@ import { TangyErrorHandler } from './shared/_services/tangy-error-handler.servic MatCardModule, MatTabsModule, MatListModule, - MatSnackBarModule + MatSnackBarModule, + TangyFormsModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [TangyErrorHandler], diff --git a/online-survey-app/src/app/case/case.module.ts b/online-survey-app/src/app/case/case.module.ts new file mode 100644 index 0000000000..4ade6ed730 --- /dev/null +++ b/online-survey-app/src/app/case/case.module.ts @@ -0,0 +1,25 @@ +import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module' +import { CaseDefinitionsService } from './services/case-definitions.service'; +import { CaseService } from './services/case.service'; +import { TangyFormsModule } from '../tangy-forms/tangy-forms.module'; + + +@NgModule({ + schemas: [ CUSTOM_ELEMENTS_SCHEMA ], + exports: [ + ], + imports: [ + SharedModule, + TangyFormsModule, + CommonModule + ], + providers: [ + CaseDefinitionsService, + CaseService + ], + declarations: [ + ] +}) +export class CaseModule { } diff --git a/online-survey-app/src/app/case/classes/case-definition.class.ts b/online-survey-app/src/app/case/classes/case-definition.class.ts new file mode 100644 index 0000000000..d3ebea9676 --- /dev/null +++ b/online-survey-app/src/app/case/classes/case-definition.class.ts @@ -0,0 +1,48 @@ +import { CaseRole } from './case-role.class'; +import { CaseEventDefinition } from './case-event-definition.class' + +class CaseDefinition { + + id:string + formId:string + name:string + caseRoles?:Array + description?:string + eventDefinitions: Array = [] + templateBreadcrumbText?:string + templateCaseTitle?:string + templateCaseDescription?:string + templateCaseEventListItemIcon?:string + templateCaseEventListItemPrimary?:string + templateCaseEventListItemSecondary?:string + templateEventFormListItemIcon?:string + templateEventFormListItemPrimary?:string + templateEventFormListItemSecondary?:string + startFormOnOpen?: CaseFormPath + templateScheduleListItemIcon?:string + templateScheduleListItemPrimary?:string + templateScheduleListItemSecondary?:string + templateIssueTitle?:string + templateIssueDescription?:string + onCaseOpen?:string + onCaseClose?:string + constructor(init:any) { + Object.assign(this, init); + /* + this.id = caseDefinitionData.id; + this.revision = caseDefinitionData.revision; + this.name = caseDefinitionData.name; + this.eventDefinitions = caseDefinitionData + .eventDefinitions + .map(eventDefinition => new CaseEventDefinition(eventDefinition)) + */ + } + +} + +class CaseFormPath { + eventId:string + eventFormId:string +} + +export { CaseDefinition } \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/case-event-definition.class.ts b/online-survey-app/src/app/case/classes/case-event-definition.class.ts new file mode 100644 index 0000000000..5729538018 --- /dev/null +++ b/online-survey-app/src/app/case/classes/case-event-definition.class.ts @@ -0,0 +1,31 @@ +import { EventFormDefinition } from './event-form-definition.class' + +class CaseEventDefinition { + + id:string + name:string + description:string + repeatable:boolean + required:boolean + templateListItemIcon:string + templateListItemPrimary:string + templateListItemSecondary:string + templateCaseEventListItemIcon:string + templateCaseEventListItemPrimary:string + templateCaseEventListItemSecondary:string + templateEventFormListItemIcon:string + templateEventFormListItemPrimary:string + templateEventFormListItemSecondary:string + estimatedTimeFromCaseOpening:number + estimatedTimeWindow:number + eventFormDefinitions:Array = [] + onEventOpen:string + onEventClose:string + onEventCreate:string + + constructor() { + } + +} + +export { CaseEventDefinition } diff --git a/online-survey-app/src/app/case/classes/case-event.class.ts b/online-survey-app/src/app/case/classes/case-event.class.ts new file mode 100644 index 0000000000..454b109469 --- /dev/null +++ b/online-survey-app/src/app/case/classes/case-event.class.ts @@ -0,0 +1,25 @@ +import { EventForm } from './event-form.class' + +class CaseEvent { + id?: string + caseId: string + caseEventDefinitionId:string + eventForms: Array = [] + complete:boolean = false + estimate = true + estimatedDay: string + scheduledDay: string + windowStartDay: string + windowEndDay: string + occurredOnDay: string + archived:boolean = false + // started date + // last updated date + + name: string + constructor() { + + } +} + +export { CaseEvent } diff --git a/online-survey-app/src/app/case/classes/case-participant.class.ts b/online-survey-app/src/app/case/classes/case-participant.class.ts new file mode 100644 index 0000000000..73b4a4deae --- /dev/null +++ b/online-survey-app/src/app/case/classes/case-participant.class.ts @@ -0,0 +1,7 @@ +interface CaseParticipant { + id:string + caseRoleId:string + data:any +} + +export { CaseParticipant } \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/case-role.class.ts b/online-survey-app/src/app/case/classes/case-role.class.ts new file mode 100644 index 0000000000..83badf5ec5 --- /dev/null +++ b/online-survey-app/src/app/case/classes/case-role.class.ts @@ -0,0 +1,7 @@ + +export interface CaseRole { + id:string + label:string + templateListItem:string +} + diff --git a/online-survey-app/src/app/case/classes/case.class.ts b/online-survey-app/src/app/case/classes/case.class.ts new file mode 100644 index 0000000000..195ab8dd79 --- /dev/null +++ b/online-survey-app/src/app/case/classes/case.class.ts @@ -0,0 +1,29 @@ +import { CaseEvent } from './case-event.class' +import {TangyFormResponseModel} from 'tangy-form/tangy-form-response-model.js' +import { CaseParticipant } from './case-participant.class'; +import { Notification } from './notification.class'; + +class Case extends TangyFormResponseModel { + + _id:string + _rev:string + caseDefinitionId:string + label:string + status:string + openedDate:number + participants:Array = [] + disabledEventDefinitionIds: Array = [] + events: Array = [] + notifications: Array = [] + type:string = 'case' + archived:boolean + groupId:string + + constructor(data?:any) { + super() + Object.assign(this, data) + } + +} + +export { Case } \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/event-form-definition.class.ts b/online-survey-app/src/app/case/classes/event-form-definition.class.ts new file mode 100644 index 0000000000..1b0782fddd --- /dev/null +++ b/online-survey-app/src/app/case/classes/event-form-definition.class.ts @@ -0,0 +1,21 @@ +export class EventFormDefinition { + + id:string + formId:string + name:string + autoPopulate:boolean + forCaseRole:string + templateListItem:string + repeatable: boolean + required:boolean + templateListItemIcon:string + templateListItemPrimary:string + templateListItemSecondary:string + allowDeleteIfFormNotCompleted:string + allowDeleteIfFormNotStarted:string + onEventFormOpen?:string + onEventFormClose?:string + + constructor() { + } +} \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/event-form.class.ts b/online-survey-app/src/app/case/classes/event-form.class.ts new file mode 100644 index 0000000000..e83d9c57c4 --- /dev/null +++ b/online-survey-app/src/app/case/classes/event-form.class.ts @@ -0,0 +1,17 @@ +class EventForm { + id:string; + participantId:string + complete:boolean = false + archived?:boolean = false + required:boolean + caseId:string; + caseEventId:string; + eventFormDefinitionId:string; + formResponseId:string; + data?:any; + constructor() { + + } +} + +export { EventForm } \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/notification.class.ts b/online-survey-app/src/app/case/classes/notification.class.ts new file mode 100644 index 0000000000..cf2140e780 --- /dev/null +++ b/online-survey-app/src/app/case/classes/notification.class.ts @@ -0,0 +1,29 @@ +import { AppContext } from './../../app-context.enum'; +export enum NotificationStatus { + Open='Open', + Closed='Closed' +} + +export enum NotificationType { + Critical='Critical', + Info='Info', +} + +class Notification { + id: string + label:string + description:string + link:string + icon:string + color:string + enforceAttention:boolean + persist:boolean + status:NotificationStatus + createdOn:number + createdAppContext:AppContext + constructor(data?:any) { + Object.assign(this, data) + } +} + +export { Notification } \ No newline at end of file diff --git a/online-survey-app/src/app/case/classes/query.class.ts b/online-survey-app/src/app/case/classes/query.class.ts new file mode 100644 index 0000000000..34d25659f2 --- /dev/null +++ b/online-survey-app/src/app/case/classes/query.class.ts @@ -0,0 +1,46 @@ +class Query { + queryId: string; + + associatedCaseId: string; + associatedCaseType: string; + associatedEventId: string; + associatedFormId: string; + associatedCaseName: string; + associatedEventName: string; + associatedFormName: string; + associatedFormLink: string; + associatedVariable: string; + + queryTypeId: string; + queryLink: string; + queryText: string; + queryResponse: string; + queryStatus: string; + queryDate: string; + queryResponseDate: string; + + constructor(queryId, associatedCaseId, associatedEventId, associatedFormId, associatedCaseName, + associatedEventName, associatedFormName, associatedFormLink, associatedCaseType, queryLink, queryDate, queryText, + queryStatus, queryResponse?, queryResponseDate?, associatedVariable?, queryTypeId?) { + + this.queryId = queryId; + this.associatedCaseId = associatedCaseId; + this.associatedCaseType = associatedCaseType; + this.associatedEventId = associatedEventId; + this.associatedFormId = associatedFormId; + this.associatedEventName = associatedEventName; + this.associatedFormName = associatedFormName; + this.associatedFormLink = associatedFormLink; + this.associatedCaseName = associatedCaseName; + this.associatedVariable = associatedVariable; + this.queryTypeId = queryTypeId; + this.queryLink = queryLink; + this.queryDate = queryDate; + this.queryText = queryText; + this.queryStatus = queryStatus; + this.queryResponse = queryResponse; + this.queryResponseDate = queryResponseDate; + } +} + +export { Query }; diff --git a/online-survey-app/src/app/case/services/case-definitions.service.spec.ts b/online-survey-app/src/app/case/services/case-definitions.service.spec.ts new file mode 100644 index 0000000000..9bc4576d96 --- /dev/null +++ b/online-survey-app/src/app/case/services/case-definitions.service.spec.ts @@ -0,0 +1,13 @@ +import { TestBed } from '@angular/core/testing'; + +import { CaseDefinitionsService } from './case-definitions.service'; +import { HttpClientModule } from '@angular/common/http'; + +describe('CaseDefinitionsService', () => { + beforeEach(() => TestBed.configureTestingModule({imports: [ HttpClientModule ]})); + + it('should be created', () => { + const service: CaseDefinitionsService = TestBed.get(CaseDefinitionsService); + expect(service).toBeTruthy(); + }); +}); diff --git a/online-survey-app/src/app/case/services/case-definitions.service.ts b/online-survey-app/src/app/case/services/case-definitions.service.ts new file mode 100644 index 0000000000..899c37603b --- /dev/null +++ b/online-survey-app/src/app/case/services/case-definitions.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { CaseDefinition } from '../classes/case-definition.class'; +import { HttpClient } from '@angular/common/http'; +import {FormInfo} from '../../tangy-forms/classes/form-info.class'; + +@Injectable({ + providedIn: 'root' +}) +export class CaseDefinitionsService { + caseDefinitionReferences:Array + caseDefinitions:Array + constructor( + private http: HttpClient + ) { } + + async load():Promise { + this.caseDefinitionReferences = this.caseDefinitionReferences ? this.caseDefinitionReferences : >await this.http.get('./assets/case-definitions.json').toPromise() + if (!this.caseDefinitions) { + this.caseDefinitions = [] + for (const reference of this.caseDefinitionReferences) { + const definition = await this.http.get(reference.src).toPromise(); + this.caseDefinitions.push(definition) + } + } + return this.caseDefinitions; + } + +} diff --git a/online-survey-app/src/app/case/services/case-event-info.class.ts b/online-survey-app/src/app/case/services/case-event-info.class.ts new file mode 100644 index 0000000000..dfafd79688 --- /dev/null +++ b/online-survey-app/src/app/case/services/case-event-info.class.ts @@ -0,0 +1,12 @@ +import { Case } from '../classes/case.class'; +import { CaseEvent } from '../classes/case-event.class'; +import { CaseDefinition } from '../classes/case-definition.class'; + + class CaseEventInfo extends CaseEvent { +caseInstance?:Case +caseDefinition?:CaseDefinition +constructor() { +super() +} +} +export {CaseEventInfo} diff --git a/online-survey-app/src/app/case/services/case.service.spec.ts b/online-survey-app/src/app/case/services/case.service.spec.ts new file mode 100644 index 0000000000..4ca59f8fdb --- /dev/null +++ b/online-survey-app/src/app/case/services/case.service.spec.ts @@ -0,0 +1,292 @@ +import { AppConfigService } from './../../shared/_services/app-config.service'; +import { CaseRole } from './../classes/case-role.class'; +import { CASE_EVENT_STATUS_COMPLETED, CASE_EVENT_STATUS_IN_PROGRESS } from './../classes/case-event.class'; +import { TestBed } from '@angular/core/testing'; + +import { CaseService } from './case.service'; +import { CaseDefinitionsService } from './case-definitions.service'; +import { TangyFormService } from 'src/app/tangy-forms/tangy-form.service'; +import { UserService } from 'src/app/shared/_services/user.service'; +// NOTE: For some reason if this is WindowRef from the shared module, this fails to inject. +import { CaseDefinition } from '../classes/case-definition.class'; +import { EventFormDefinition } from '../classes/event-form-definition.class'; +import { CaseEventDefinition } from '../classes/case-event-definition.class'; +import PouchDB from 'pouchdb'; +import { HttpClient } from '@angular/common/http'; +import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing'; +import { CaseParticipant } from '../classes/case-participant.class'; +import moment from 'moment/src/moment'; +class MockCaseDefinitionsService { + async load() { + return >[ + { + 'id': 'caseDefinition1', + 'formId': 'caseDefinition1Form', + 'name': 'Case Type 1', + 'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + 'caseRoles': [ + { + id: 'role1', + label: 'Role 1' + }, + { + id: 'role2', + label: 'Role 2' + } + ], + 'eventDefinitions': >[ + { + 'id': 'event-definition-screening', + 'name': 'Screening', + 'description': 'A screening.', + 'repeatable': false, + 'required': true, + 'estimatedTimeFromCaseOpening': 0, + 'estimatedTimeWindow': 0, + 'eventFormDefinitions': [ + { + 'id': 'event-form-1', + 'formId': 'form1', + 'forCaseRole': 'role1', + 'name': 'Form 1', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-2', + 'formId': 'form2', + 'forCaseRole': 'role2', + 'name': 'Form 2', + 'required': true, + 'repeatable': false + } + ] + }, + { + 'id': 'event-definition-first-visit', + 'name': 'First visit', + 'description': 'The first visit', + 'repeatable': false, + 'required': true, + 'estimatedTimeFromCaseOpening': 15552000000, + 'estimatedTimeWindow': 2592000000, + 'eventFormDefinitions': [ + { + 'id': 'event-form-1', + 'formId': 'form1', + 'forCaseRole': 'role1', + 'name': 'Form 1', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-2', + 'formId': 'form2', + 'forCaseRole': 'role1', + 'name': 'Form 2 (repeatable)', + 'required': true, + 'repeatable': true + }, + { + 'id': 'event-form-3', + 'formId': 'form3', + 'forCaseRole': 'role1', + 'name': 'Form 3', + 'required': false, + 'repeatable': false + } + ] + }, + { + 'id': 'event-definition-repeatable-event', + 'name': 'A Repeatable Event', + 'description': 'A repeatable event.', + 'repeatable': true, + 'required': true, + 'estimatedTimeFromCaseOpening': 0, + 'estimatedTimeWindow': 0, + 'eventFormDefinitions': [ + { + 'id': 'event-form-1', + 'formId': 'form1', + 'forCaseRole': 'role1', + 'name': 'Form 1', + 'required': true, + 'repeatable': false + } + ] + }, + { + 'id': 'event-definition-not-required-event', + 'name': 'A Event that is not require', + 'description': 'An event that is not required.', + 'repeatable': true, + 'required': false, + 'estimatedTimeFromCaseOpening': 0, + 'estimatedTimeWindow': 0, + 'eventFormDefinitions': [ + { + 'id': 'event-form-1', + 'formId': 'form1', + 'forCaseRole': 'role1', + 'name': 'Form 1', + 'required': true, + 'repeatable': false + } + ] + } + ] + } + ] + } +} + +class MockTangyFormService { + + response:any + + async getFormMarkup(formId) { + return ` + + + + + + ` + } + async saveResponse(response) { + this.response = response + /// + } + async getResponse(id) { + return this.response + /// + } +} + +class MockUserService { + getCurrentUser() { + return 'test' + } + getUserDatabase(username) { + return new PouchDB('test') + } +} + +describe('CaseService', () => { + + let httpClient: HttpClient + let httpTestingController: HttpTestingController + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + { + provide: CaseDefinitionsService, + useClass: MockCaseDefinitionsService + }, + { + provide: TangyFormService, + useClass: MockTangyFormService + }, + { + provide: UserService, + useClass: MockUserService + }, + { + provide: AppConfigService, + useClass: AppConfigService + } + ] + }) + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + }); + + it('should be created', () => { + const service: CaseService = TestBed.get(CaseService); + expect(service).toBeTruthy(); + }); + + it('should create a case' , async () => { + const service: CaseService = TestBed.get(CaseService); + await service.create('caseDefinition1') + expect(service.case._id).toBeTruthy() + }) + + it('should set an event ocurred on date', async () => { + const service: CaseService = TestBed.get(CaseService) + await service.create('caseDefinition1') + await service.createEvent('event-definition-first-visit') + const timeInMs = new Date().getTime() + const date = moment((new Date(timeInMs))).format('YYYY-MM-DD') + service.setEventOccurredOn(service.case.events[0].id, timeInMs) + expect(service.case.events[0].occurredOnDay).toEqual(date) + }) + it('should set an event EstimatedDay date', async () => { + const service: CaseService = TestBed.get(CaseService) + await service.create('caseDefinition1') + await service.createEvent('event-definition-first-visit') + const timeInMs = new Date().getTime() + const date = moment((new Date(timeInMs))).format('YYYY-MM-DD') + service.setEventEstimatedDay(service.case.events[0].id, timeInMs) + expect(service.case.events[0].estimatedDay).toEqual(date) + }) + it('should set an event ScheduledDay date', async () => { + const service: CaseService = TestBed.get(CaseService) + await service.create('caseDefinition1') + await service.createEvent('event-definition-first-visit') + const timeInMs = new Date().getTime() + const date = moment((new Date(timeInMs))).format('YYYY-MM-DD') + service.setEventScheduledDay(service.case.events[0].id, timeInMs) + expect(service.case.events[0].scheduledDay).toEqual(date) + }) + it('should set an event Window period', async () => { + const service: CaseService = TestBed.get(CaseService) + await service.create('caseDefinition1') + await service.createEvent('event-definition-first-visit') + const windowStartDayTimeInMs = new Date().getTime() + const windowEndDayTimeInMs = new Date().getTime() + const windowStartDay = moment((new Date(windowStartDayTimeInMs))).format('YYYY-MM-DD') + const windowEndDay = moment((new Date(windowEndDayTimeInMs))).format('YYYY-MM-DD') + service.setEventWindow(service.case.events[0].id, windowStartDayTimeInMs, windowEndDayTimeInMs) + expect(service.case.events[0].windowStartDay).toEqual(windowStartDay) + expect(service.case.events[0].windowEndDay).toEqual(windowEndDay) + }) + + it('should create participant and create forms for existing events', async () => { + const service: CaseService = TestBed.get(CaseService) + await service.create('caseDefinition1') + await service.createEvent('event-definition-screening', true) + expect(service.case.events[0].eventForms.length).toEqual(0) + const caseParticipant = await service.createParticipant('role1') + expect(service.case.participants[0].id).toEqual(caseParticipant.id) + expect(service.case.events[0].eventForms.length).toEqual(1) + }) + + it('should create CaseEvent and also create corresponding required EventForms for Participants', async() => { + const service: CaseService = TestBed.get(CaseService); + await service.create('caseDefinition1') + const caseParticipant = await service.createParticipant('role1') + await service.createEvent('event-definition-screening', true) + expect(service.case.participants[0].id).toEqual(caseParticipant.id) + expect(service.case.events[0].eventForms.length).toEqual(1) + }) + + it('CaseEvent should have status of comleted when all required forms are completed', async () => { + const service: CaseService = TestBed.get(CaseService); + await service.create('caseDefinition1') + const caseParticipant = await service.createParticipant('role1') + const caseParticipant2 = await service.createParticipant('role2') + const caseEvent = await service.createEvent('event-definition-screening', true) + expect(service.case.events[0].status).toEqual(CASE_EVENT_STATUS_IN_PROGRESS) + for (const eventForm of service.case.events[0].eventForms) { + service.markEventFormComplete(caseEvent.id, eventForm.id) + } + expect(service.case.events[0].status).toEqual(CASE_EVENT_STATUS_COMPLETED) + }) + +}); diff --git a/online-survey-app/src/app/case/services/case.service.ts b/online-survey-app/src/app/case/services/case.service.ts new file mode 100644 index 0000000000..20b084ba35 --- /dev/null +++ b/online-survey-app/src/app/case/services/case.service.ts @@ -0,0 +1,1101 @@ +import { AppConfigService, LocationNode } from './../../shared/_services/app-config.service'; +import { EventFormDefinition } from './../classes/event-form-definition.class'; +import { Subject } from 'rxjs'; +import { NotificationStatus, Notification, NotificationType } from './../classes/notification.class'; +// Services. +import { TangyFormService } from 'src/app/tangy-forms/tangy-form.service'; +import { CaseDefinitionsService } from './case-definitions.service'; +import { HttpClient } from '@angular/common/http'; +import { UserService } from 'src/app/core/auth/_services/user.service'; +// Classes. +import { TangyFormResponseModel } from 'tangy-form/tangy-form-response-model.js'; +import { Case } from '../classes/case.class' +import { CaseEvent } from '../classes/case-event.class' +import { EventForm } from '../classes/event-form.class' +import { CaseDefinition } from '../classes/case-definition.class'; +import { CaseParticipant } from '../classes/case-participant.class'; +import { Query } from '../classes/query.class' +// Other. +import { v4 as UUID } from 'uuid'; +import { Injectable } from '@angular/core'; +import * as moment from 'moment'; +import { AppContext } from 'src/app/app-context.enum'; +import { CaseEventDefinition } from '../classes/case-event-definition.class'; + + +@Injectable({ + providedIn: 'root' +}) +class CaseService { + + _case:Case + caseDefinition:CaseDefinition + location:Array + + // Opening a case confirmation semaphore. + openCaseConfirmed = false + // Query properties. + queryCaseEventDefinitionId: any + queryEventFormDefinitionId: any + queryFormId: any + _shouldSave = true + + + set case(caseInstance:Case) { + const caseInfo:CaseInfo = { + caseInstance, + caseDefinition: this.caseDefinition + } + this._case = markQualifyingCaseAsComplete(markQualifyingEventsAsComplete(caseInfo)).caseInstance + } + + get case():Case { + return this._case + } + + caseEvent:CaseEvent + caseEventDefinition:CaseEventDefinition + eventForm:EventForm + eventFormDefinition:EventFormDefinition + participant:CaseParticipant + + setContext(caseEventId = '', eventFormId = '') { + window['caseInstance'] = this.case + this.caseEvent = caseEventId + ? this.case + .events + .find(caseEvent => caseEvent.id === caseEventId) + : null + window['caseEvent'] = this.caseEvent + this.caseEventDefinition = caseEventId + ? this + .caseDefinition + .eventDefinitions + .find(caseEventDefinition => caseEventDefinition.id === this.caseEvent.caseEventDefinitionId) + : null + window['caseEventDefinition'] = this.caseEventDefinition + this.eventForm = eventFormId + ? this.caseEvent + .eventForms + .find(eventForm => eventForm.id === eventFormId) + : null + window['eventForm'] = this.eventForm + this.eventFormDefinition = eventFormId + ? this.caseEventDefinition + .eventFormDefinitions + .find(eventFormDefinition => eventFormDefinition.id === this.eventForm.eventFormDefinitionId) + : null + this.participant = this.eventForm + ? this.case.participants.find(participant => participant.id === this.eventForm.participantId) + : null + window['participant'] = this.participant + } + + getCurrentCaseEventId() { + return this?.caseEvent?.id + } + + getCurrentEventFormId() { + return this?.eventForm?.id + } + + constructor( + private tangyFormService: TangyFormService, + private caseDefinitionsService: CaseDefinitionsService, + private userService:UserService, + private appConfigService:AppConfigService, + private http:HttpClient + ) { + this.queryCaseEventDefinitionId = 'query-event'; + this.queryEventFormDefinitionId = 'query-form-event'; + this.queryFormId = 'query-form'; + } + + /* + * Case API + */ + + get id() { + return this._case._id + } + + get participants() { + return this._case.participants || [] + } + + get events() { + return this._case.events || [] + } + + get forms() { + return this._case.events.reduce((forms, event) => { + return [ + ...event.eventForms || [], + ...forms + ] + }, []) + } + + get groupId() { + return this._case.groupId + } + + get roleDefinitions() { + return this.caseDefinition.caseRoles + } + + get caseEventDefinitions() { + return this.caseDefinition.eventDefinitions || [] + } + + get eventFormDefinitions() { + return this.caseDefinition.eventDefinitions.reduce((formDefs, eventDef) => { + return [ + ...eventDef.eventFormDefinitions.map(eventFormDef => { + return { + ...eventFormDef, + eventDefinitionId: eventDef.id + } + }) || [], + ...formDefs + ] + }, []) + } + + async create(caseDefinitionId) { + this.caseDefinition = (await this.caseDefinitionsService.load()) + .find(caseDefinition => caseDefinition.id === caseDefinitionId) + this.case = new Case({caseDefinitionId, events: [], _id: UUID()}) + delete this.case._rev + const tangyFormContainerEl:any = document.createElement('div') + tangyFormContainerEl.innerHTML = await this.tangyFormService.getFormMarkup(this.caseDefinition.formId, null) + const tangyFormEl = tangyFormContainerEl.querySelector('tangy-form') + tangyFormEl.style.display = 'none' + document.body.appendChild(tangyFormContainerEl) + this.case.form = tangyFormEl.getProps() + this.case.items = [] + tangyFormEl.querySelectorAll('tangy-form-item').forEach((item) => { + this.case.items.push(item.getProps()) + }) + tangyFormEl.querySelectorAll('tangy-form-item')[0].submit() + this.case.items[0].inputs = tangyFormEl.querySelectorAll('tangy-form-item')[0].inputs + tangyFormEl.response = this.case + this.case = {...this.case, ...tangyFormEl.response} + tangyFormContainerEl.remove() + await this.setCase(this.case) + this.case.caseDefinitionId = caseDefinitionId; + this.case.label = this.caseDefinition.name + await this.save() + } + + async archive() { + const eventForms:Array = this.case.events.reduce((eventForms, event) => { + return Array.isArray(event.eventForms) + ? [...eventForms, ...event.eventForms] + : eventForms + }, []) + for (let eventForm of eventForms) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = true + await this.tangyFormService.saveResponse(formResponse) + } + } + } + this.case.archived=true + await this.save() + } + + async unarchive() { + const eventForms:Array = this.case.events.reduce((eventForms, event) => { + return Array.isArray(event.eventForms) + ? [...eventForms, ...event.eventForms] + : eventForms + }, []) + for (let eventForm of eventForms) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = false + await this.tangyFormService.saveResponse(formResponse) + } + } + } + this.case.archived = false + await this.save() + } + + async delete() { + const eventForms:Array = this.case.events.reduce((eventForms, event) => { + return Array.isArray(event.eventForms) + ? [...eventForms, ...event.eventForms] + : eventForms + }, []) + for (let eventForm of eventForms) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + const archivedFormResponse = new TangyFormResponseModel( + { + archived:true, + _rev : formResponse._rev, + _id : formResponse._id, + form : { + id: formResponse.form.id, + title: formResponse.form.title, + tagName: formResponse.form.tagName, + complete: formResponse.form.complete + }, + items : [], + events : [], + location : formResponse.location, + type : "response", + caseId: formResponse.caseId, + eventId: formResponse.eventId, + eventFormId: formResponse.eventFormId, + participantId: formResponse.participantId, + groupId: formResponse.groupId, + complete: formResponse.complete, + tangerineModifiedOn: new Date().getTime() + } + ) + await this.tangyFormService.saveResponse(archivedFormResponse) + } + } + } + + this.case.archived=true + // Keeping inputs so that the case show up in searches *on the server* + const archivedCase = new Case( + { + archived:true, + _rev : this.case._rev, + _id : this.case._id, + form : { + id: this.case.form.id, + tagName: this.case.form.tagName, + complete: this.case.form.complete + }, + items : [{}], + events : [], + location : this.case.location, + type : "case", + caseDefinitionId: this.case.caseDefinitionId, + groupId: this.case['groupId'], + complete: this.case.complete, + tangerineModifiedOn: new Date().getTime() + } + ) + if (this.case.items[0]) { + archivedCase.items[0].id = this.case.items[0].id + archivedCase.items[0].title = this.case.items[0].title + archivedCase.items[0].tagName = this.case.items[0].tagName + archivedCase.items[0].inputs = this.case.items[0].inputs + } + await this.tangyFormService.saveResponse(archivedCase) + } + + async setCase(caseInstance) { + // Note the order of setting caseDefinition before case matters because the setter for case expects caseDefinition to be the current one. + this.caseDefinition = (await this.caseDefinitionsService.load()) + .find(caseDefinition => caseDefinition.id === caseInstance.caseDefinitionId) + const flatLocationList = await this.appConfigService.getFlatLocationList() + this.location = Object.keys(caseInstance.location).map(level => flatLocationList.locations.find(node => node.id === caseInstance.location[level])) + this.case = caseInstance + } + + async load(id:string) { + await this.setCase(new Case(await this.tangyFormService.getResponse(id))) + this._shouldSave = true + } + + async loadInMemory(caseData:Case) { + await this.setCase(new Case(caseData)) + this._shouldSave = false + } + + onChangeLocation$ = new Subject() + + // @Param location: Can be Object where keys are levels and values are IDs of locations or an Array from the Tangy Location input's value. + async changeLocation(location:any):Promise { + location = Array.isArray(location) + ? location.reduce((location, level) => { return {...location, [level.level]: level.value} }, {}) + : location + this.case.location = location + const eventForms:Array = this.case.events + .reduce((eventForms, event) => { + return [...eventForms, ...event.eventForms] + }, []) + for (let eventForm of eventForms) { + if (eventForm.formResponseId) { + const response = await this.tangyFormService.getResponse(eventForm.formResponseId) + response.location = location + await this.tangyFormService.saveResponse(response) + } + } + this.onChangeLocation$.next(location) + } + + async getCaseDefinitionByID(id:string) { + return await this.http.get(`./assets/${id}.json`) + .toPromise() + } + + async save() { + if (this._shouldSave) { + await this.tangyFormService.saveResponse(this.case) + await this.setCase(await this.tangyFormService.getResponse(this.case._id)) + } + } + + setVariable(variableName, value) { + let input = this.case.items[0].inputs.find(input => input.name === variableName) + if (input) { + input.value = value + } else { + this.case.items[0].inputs.push({name: variableName, value: value}) + } + } + + getVariable(variableName) { + return this.case.items[0].inputs.find(input => input.name === variableName) + ? this.case.items[0].inputs.find(input => input.name === variableName).value + : undefined + } + + /* + * Case Event API + */ + + createEvent(eventDefinitionId:string) { + const caseEventDefinition = this.caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === eventDefinitionId) + const caseEvent = { + id: UUID(), + caseId: this.case._id, + name: caseEventDefinition.name, + complete: false, + estimate: true, + caseEventDefinitionId: eventDefinitionId, + windowEndDay: undefined, + windowStartDay: undefined, + estimatedDay: undefined, + occurredOnDay: undefined, + scheduledDay: undefined, + eventForms: [], + startDate: 0, + archived: false + } + this.case.events.push(caseEvent) + for (const caseParticipant of this.case.participants) { + for (const eventFormDefinition of caseEventDefinition.eventFormDefinitions) { + if (eventFormDefinition.forCaseRole.split(',').map(e=>e.trim()).includes(caseParticipant.caseRoleId) + && + ( + eventFormDefinition.autoPopulate || + (eventFormDefinition.autoPopulate === undefined && eventFormDefinition.required === true) + ) + ) { + this.createEventForm(caseEvent.id, eventFormDefinition.id, caseParticipant.id) + } + } + } + + return caseEvent + } + + async onCaseEventCreate(caseEvent: CaseEvent) { + const caseEventDefinition = this.caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === caseEvent.caseEventDefinitionId) + + await eval(caseEventDefinition.onEventCreate) + } + + setEventName(eventId, name:string) { + this.case.events = this.case.events.map(event => { + return event.id === eventId + ? { ...event, ...{ name } } + : event + }) + } + + setEventEstimatedDay(eventId, timeInMs: number) { + const estimatedDay = moment((new Date(timeInMs))).format('YYYY-MM-DD') + this.case.events = this.case.events.map(event => { + return event.id === eventId + ? { ...event, ...{ estimatedDay } } + : event + }) + } + setEventScheduledDay(eventId, timeInMs: number) { + const scheduledDay = moment((new Date(timeInMs))).format('YYYY-MM-DD') + this.case.events = this.case.events.map(event => { + return event.id === eventId + ? { ...event, ...{ scheduledDay } } + : event + }) + } + setEventWindow(eventId: string, windowStartDayTimeInMs: number, windowEndDayTimeInMs: number) { + const windowStartDay = moment((new Date(windowStartDayTimeInMs))).format('YYYY-MM-DD') + const windowEndDay = moment((new Date(windowEndDayTimeInMs))).format('YYYY-MM-DD') + this.case.events = this.case.events.map(event => { + return event.id === eventId + ? { ...event, ...{ windowStartDay, windowEndDay } } + : event + }) + } + setEventOccurredOn(eventId, timeInMs: number) { + const occurredOnDay = moment((new Date(timeInMs))).format('YYYY-MM-DD') + return this.case.events = this.case.events.map(event => { + return event.id === eventId + ? { ...event, ...{ occurredOnDay } } + : event + }) + } + + disableEventDefinition(eventDefinitionId) { + if (this.case.disabledEventDefinitionIds.indexOf(eventDefinitionId) === -1) { + this.case.disabledEventDefinitionIds.push(eventDefinitionId) + } + } + + activateCaseEvent(caseEventId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => { + return event.id === caseEventId + ? { + ...event, + inactive: false + } + : event + }) + } + } + + deactivateCaseEvent(caseEventId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => { + return event.id === caseEventId + ? { + ...event, + inactive: true + } + : event + }) + } + } + + async archiveCaseEvent(caseEventId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent && !caseEvent.archived) { + var unarchivedEventForms = caseEvent.eventForms.filter(form => !form.archived) + for (var eventForm of unarchivedEventForms) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = true + await this.tangyFormService.saveResponse(formResponse) + } + } + eventForm.archived = true + } + caseEvent.archived = true + await this.save() + } + } + + async unarchiveCaseEvent(caseEventId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent && caseEvent.archived) { + var archivedEventForms = caseEvent.eventForms.filter(form => form.archived) + for (var eventForm of archivedEventForms) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = false + await this.tangyFormService.saveResponse(formResponse) + } + } + eventForm.archived = false + } + caseEvent.archived = false + await this.save() + } + } + + /* + * Event Form API + */ + + createEventForm(caseEventId, eventFormDefinitionId, participantId = ''): EventForm { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + const eventFormId = UUID() + this.case = { + ...this.case, + events: this.case.events.map(event => event.id === caseEventId + ? { + ...event, + eventForms: [ + ...event.eventForms, + { + id: eventFormId, + complete: false, + required: this + .caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === caseEvent.caseEventDefinitionId) + .eventFormDefinitions + .find(eventFormDefinition => eventFormDefinition.id === eventFormDefinitionId) + .required, + caseId: this.case._id, + participantId, + caseEventId, + eventFormDefinitionId + } + ] + } + : event + ) + } + return this.case + .events + .find(event => event.id === caseEvent.id) + .eventForms + .find(eventForm => eventForm.id === eventFormId) + } + + // @TODO Deprecated. + startEventForm(caseEventId, eventFormDefinitionId, participantId = ''): EventForm { + console.warn('caseService.startEventForm(...) is deprecated. Please use caseService.createEventForm(...) before startEventForm is removed.') + return this.createEventForm(caseEventId, eventFormDefinitionId, participantId) + } + + deleteEventForm(caseEventId: string, eventFormId: string) { + this.case = { + ...this.case, + events: this.case.events.map(event => { + return { + ...event, + eventForms: event.id === caseEventId + ? event.eventForms.filter(eventForm => eventForm.id !== eventFormId) + : event.eventForms + } + }) + } + } + + setEventFormData(caseEventId:string,eventFormId:string, key:string, value:string) { + const index = this + .case + .events + .find(caseEvent => caseEvent.id === caseEventId) + .eventForms.findIndex(eventForm => eventForm.id === eventFormId); + this + .case + .events + .find(caseEvent => caseEvent.id === caseEventId).eventForms[index].data = { + ...this + .case + .events + .find(caseEvent => caseEvent.id === caseEventId).eventForms[index].data, [key]:value}; + } + + getEventFormData(caseEventId:string,eventFormId:string, key:string,) { + const index = this + .case + .events + .find(caseEvent => caseEvent.id === caseEventId) + .eventForms.findIndex(eventForm => eventForm.id === eventFormId); + return this + .case + .events + .find(caseEvent => caseEvent.id === caseEventId).eventForms[index].data[key] || '' + } + + markEventFormRequired(caseEventId:string, eventFormId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => event.id !== caseEventId + ? event + : { + ...event, + eventForms: event.eventForms.map(eventForm => eventForm.id !== eventFormId + ? eventForm + : { + ...eventForm, + required: true + } + ) + } + ) + } + } + + markEventFormNotRequired(caseEventId:string, eventFormId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => event.id !== caseEventId + ? event + : { + ...event, + eventForms: event.eventForms.map(eventForm => eventForm.id !== eventFormId + ? eventForm + : { + ...eventForm, + required: false + } + ) + } + ) + } + } + + markEventFormComplete(caseEventId:string, eventFormId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => event.id !== caseEventId + ? event + : { + ...event, + eventForms: event.eventForms.map(eventForm => eventForm.id !== eventFormId + ? eventForm + : { + ...eventForm, + complete: true + } + ) + } + ) + } + } + + activateEventForm(caseEventId:string, eventFormId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => event.id !== caseEventId + ? event + : { + ...event, + eventForms: event.eventForms.map(eventForm => eventForm.id !== eventFormId + ? eventForm + : { + ...eventForm, + inactive: false + } + ) + } + ) + } + } + + deactivateEventForm(caseEventId:string, eventFormId:string) { + this.case = { + ...this.case, + events: this.case.events.map(event => event.id !== caseEventId + ? event + : { + ...event, + eventForms: event.eventForms.map(eventForm => eventForm.id !== eventFormId + ? eventForm + : { + ...eventForm, + inactive: true + } + ) + } + ) + } + } + + async archiveEventForm(caseEventId:string, eventFormId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent) { + var eventForm = caseEvent.eventForms.find(form => form.id === eventFormId) + if (eventForm) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse && !formResponse.archived) { + formResponse.archived = true + await this.tangyFormService.saveResponse(formResponse) + } + } + eventForm.archived = true + await this.save() + } + } + } + + async unarchiveEventForm(caseEventId:string, eventFormId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent) { + var eventForm = caseEvent.eventForms.find(form => form.id === eventFormId) + if (eventForm) { + if (eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse && formResponse.archived) { + formResponse.archived = false + await this.tangyFormService.saveResponse(formResponse) + } + } + eventForm.archived = false + await this.save() + } + } + } + + /* + * Form Response API + */ + + async archiveFormResponse(caseEventId:string, eventFormId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent) { + var eventForm = caseEvent.eventForms.find(form => form.id === eventFormId) + if (eventForm && eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = true + await this.tangyFormService.saveResponse(formResponse) + } + eventForm.archived = true + await this.save() + } + } + } + + async unarchiveFormResponse(caseEventId:string, eventFormId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent) { + var eventForm = caseEvent.eventForms.find(form => form.id === eventFormId) + if (eventForm && eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + formResponse.archived = false + await this.tangyFormService.saveResponse(formResponse) + } + eventForm.archived = false + await this.save() + } + } + } + + async deleteFormResponse(caseEventId:string, eventFormId:string) { + const caseEvent = this.case.events.find(event => event.id === caseEventId) + if (caseEvent) { + var eventForm = caseEvent.eventForms.find(form => form.id === eventFormId) + if (eventForm && eventForm.formResponseId) { + const formResponse = await this.tangyFormService.getResponse(eventForm.formResponseId) + if (formResponse) { + const archivedFormResponse = new TangyFormResponseModel( + { + archived:true, + _rev : formResponse._rev, + _id : formResponse._id, + form : { + id: formResponse.form.id, + title: formResponse.form.title, + tagName: formResponse.form.tagName, + complete: formResponse.form.complete + }, + items : [], + events : [], + location : formResponse.location, + type : "response", + caseId: formResponse.caseId, + eventId: formResponse.eventId, + eventFormId: formResponse.eventFormId, + participantId: formResponse.participantId, + groupId: formResponse.groupId, + complete: formResponse.complete, + tangerineModifiedOn: new Date().getTime() + } + ) + await this.tangyFormService.saveResponse(archivedFormResponse) + } + this.deleteEventForm(caseEventId, eventFormId) + await this.save() + } + } + } + + /* + * Participant API + */ + + createParticipant(caseRoleId = ''):CaseParticipant { + const id = UUID() + const data = {} + const caseParticipant = { + id, + caseRoleId, + data + } + this.case.participants.push(caseParticipant) + for (let caseEvent of this.case.events) { + const caseEventDefinition = this + .caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === caseEvent.caseEventDefinitionId) + for (let eventFormDefinition of caseEventDefinition.eventFormDefinitions) { + if (eventFormDefinition.forCaseRole.split(',').map(e=>e.trim()).includes(caseRoleId) + && + ( + eventFormDefinition.autoPopulate || + (eventFormDefinition.autoPopulate === undefined && eventFormDefinition.required === true) + ) + ) { + this.createEventForm(caseEvent.id, eventFormDefinition.id, caseParticipant.id) + } + } + } + return caseParticipant + } + + setParticipantData(participantId:string, key:string, value:string) { + const index = this.case.participants.findIndex(participant => participant.id === participantId) + this.case.participants[index].data[key] = value + } + + getParticipantData(participantId:string, key:string) { + return this.case.participants.find(participant => participant.id === participantId).data[key] + } + + addParticipant(caseParticipant:CaseParticipant) { + this.case.participants.push(caseParticipant) + for (let caseEvent of this.case.events) { + const caseEventDefinition = this + .caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === caseEvent.caseEventDefinitionId) + for (let eventFormDefinition of caseEventDefinition.eventFormDefinitions) { + if ( + eventFormDefinition.forCaseRole.split(',').map(e=>e.trim()).includes(caseParticipant.caseRoleId) + && + ( + eventFormDefinition.autoPopulate || + (eventFormDefinition.autoPopulate === undefined && eventFormDefinition.required === true) + ) + ) { + this.createEventForm(caseEvent.id, eventFormDefinition.id, caseParticipant.id) + } + } + } + } + + async activateParticipant(participantId:string) { + this.case = { + ...this.case, + participants: this.case.participants.map(participant => { + return participant.id === participantId + ? { + ...participant, + inactive: false + } + : participant + }) + } + } + + async deactivateParticipant(participantId:string) { + this.case = { + ...this.case, + participants: this.case.participants.map(participant => { + return participant.id === participantId + ? { + ...participant, + inactive: true + } + : participant + }) + } + } + + + async getParticipantFromAnotherCase(sourceCaseId, sourceParticipantId) { + const currCaseId = this.case._id + + await this.load(sourceCaseId) + const sourceCase = this.case + const sourceParticipant = sourceCase.participants.find(sourceParticipant => + sourceParticipant.id === sourceParticipantId) + + await this.load(currCaseId) + + return sourceParticipant + } + + async deleteParticipantInAnotherCase(sourceCaseId, sourceParticipantId) { + const currCaseId = this.case._id + + await this.load(sourceCaseId) + this.case.participants = this.case.participants.filter(sourceParticipant => + sourceParticipant.id === sourceParticipantId) + await this.save() + + await this.load(currCaseId) + } + + async copyParticipantFromAnotherCase(sourceCaseId, sourceParticipantId) { + const caseParticipant = await this.getParticipantFromAnotherCase(sourceCaseId, sourceParticipantId) + if (caseParticipant !== undefined) { + this.addParticipant(caseParticipant) + } + } + + async moveParticipantFromAnotherCase(sourceCaseId, sourceParticipantId) { + const caseParticipant = await this.getParticipantFromAnotherCase(sourceCaseId, sourceParticipantId) + if (caseParticipant !== undefined) { + this.addParticipant(caseParticipant) + + // Only delete the participant from the other case after adding it to this case is successful + await this.deleteParticipantInAnotherCase(sourceCaseId, sourceParticipantId) + } + } + + async searchForParticipant(username:string, phrase:string, limit = 50, skip = 0, unique = true):Promise> { + const db = await window['T'].user.getUserDatabase(username) + const result = await db.query( + 'participantSearch', + phrase + ? { + startkey: `${phrase}`.toLocaleLowerCase(), + endkey: `${phrase}\uffff`.toLocaleLowerCase(), + include_docs: true, + limit, + skip + } + : { + include_docs: true, + limit, + skip + } + ) + const searchResults = result.rows.map(row => { + return { + ...row.value, + case: row.doc, + participant: row.doc.participants.find(p => p.id === row.value.participantId) + } + }) + // Deduplicate the search results since the same case may match on multiple variables. + return unique + ? searchResults.reduce((uniqueResults, result) => { + return uniqueResults.find(x => x.participantId === result.participantId) + ? uniqueResults + : [ ...uniqueResults, result ] + }, []) + : searchResults + } + + /* + * Notification API + */ + + createNotification (label = '', description = '', link = '', icon = 'notification_important', color = '#CCC', persist = false, enforceAttention = false ) { + const notification = { + id: UUID(), + status: NotificationStatus.Open, + createdAppContext: AppContext.Client, + createdOn: Date.now(), + label, + description, + link, + icon, + color, + enforceAttention, + persist + } + this.case.notifications.push(notification) + } + + async openNotification(notificationId:string) { + this.case.notifications = this.case.notifications.map(notification => { + return notification.id === notificationId + ? { + ...notification, + status: NotificationStatus.Open + } + : notification + }) + } + + async closeNotification(notificationId:string) { + this.case.notifications = this.case.notifications.map(notification => { + return notification.id === notificationId + ? { + ...notification, + status: NotificationStatus.Closed + } + : notification + }) + } +} + +interface CaseInfo { + caseInstance: Case + caseDefinition:CaseDefinition +} + +// @TODO We should revisit this logic. Not sure it's what we want. +export const markQualifyingCaseAsComplete = ({caseInstance, caseDefinition}:CaseInfo):CaseInfo => { + // Check to see if all required Events are complete in Case. If so, mark Case complete. + let numberOfCaseEventsRequired = caseDefinition + .eventDefinitions + .reduce((acc, definition) => definition.required ? acc + 1 : acc, 0) + let numberOfUniqueCompleteCaseEvents = caseInstance + .events + .reduce((acc, instance) => instance.complete === true + ? Array.from(new Set([...acc, instance.caseEventDefinitionId])) + : acc + , []) + .length + caseInstance + .complete = numberOfCaseEventsRequired === numberOfUniqueCompleteCaseEvents ? true : false + return { caseInstance, caseDefinition } +} + +export const markQualifyingEventsAsComplete = ({caseInstance, caseDefinition}:CaseInfo):CaseInfo => { + return { + caseInstance: { + ...caseInstance, + events: caseInstance.events.map(event => { + return { + ...event, + complete: !caseDefinition + .eventDefinitions + .find(eventDefinition => eventDefinition.id === event.caseEventDefinitionId) + .eventFormDefinitions + .some(eventFormDefinition => { + // 1. Is required and has no Event Form instances. + return ( + eventFormDefinition.required === true && + !event.eventForms.some(eventForm => eventForm.eventFormDefinitionId === eventFormDefinition.id) + ) || + // 2. Is required and at least one Event Form instance is not complete. + ( + eventFormDefinition.required === true && + event.eventForms + .filter(eventForm => eventForm.eventFormDefinitionId === eventFormDefinition.id) + .some(eventForm => !eventForm.complete) + ) || + // 3. Is not required and has at least one Event Form instance that is both incomplete and required. + ( + eventFormDefinition.required === false && + event.eventForms + .filter(eventForm => eventForm.eventFormDefinitionId === eventFormDefinition.id) + .some(eventForm => !eventForm.complete && eventForm.required) + ) + }) + } + }) + }, + caseDefinition + } +} + +export { CaseService }; diff --git a/online-survey-app/src/app/case/services/cases.service.spec.ts b/online-survey-app/src/app/case/services/cases.service.spec.ts new file mode 100644 index 0000000000..1ccaee9023 --- /dev/null +++ b/online-survey-app/src/app/case/services/cases.service.spec.ts @@ -0,0 +1,264 @@ +import { TestBed } from '@angular/core/testing'; + +import { CasesService } from './cases.service'; +import { UserService } from 'src/app/shared/_services/user.service'; +import { HttpClientModule } from '@angular/common/http'; +import { CaseEventInfo } from './case-event-info.class'; +import { CASE_EVENT_STATUS_IN_PROGRESS } from '../classes/case-event.class'; + +class MockPouchDB { + allDocs(options) { + return { + rows: [ + { + _id: 'doc1', + doc: { + _id: 'doc1', + collection: 'TangyFormResponse', + type: 'case', + events: [ + // Estimated lower bound overlap. + { + id: 'e1', + estimate: true, + dateStart: 1, + dateEnd: 10 + }, + // Estimated in bounds. + { + id: 'e2', + estimate: true, + dateStart: 9, + dateEnd: 11 + }, + // Estimated upper bounds overlap. + { + id: 'e3', + estimate: true, + dateStart: 10, + dateEnd: 20 + }, + // Estimated full overlap. + { + id: 'e4', + estimate: true, + dateStart: 1, + dateEnd: 20 + }, + // Estimated out of bounds. + { + id: 'e5', + estimate: true, + dateStart: 20, + dateEnd: 30 + }, + // Scheduled in bounds. + { + id: 'e6', + estimate: false, + dateStart: 10, + dateEnd: 10 + }, + // Scheduled out of bounds. + { + id: 'e7', + estimate: false, + dateStart: 30, + dateEnd: 30 + }, + ] + } + } + ] + } + } +} + +class MockUserService { + getCurrentUser() { + return 'test' + } + getUserDatabase(username) { + return new MockPouchDB() + } +} + +const REFERENCE_TIME = '2019-08-13' +const REFERENCE_TIME_2 = '2019-12-31' + +class MockCasesService { + + async getEventsByDate(username: string, dateStart, dateEnd, excludeEstimates = false): Promise> { + const caseDefinition= { + 'id': 'c1', + 'formId': 'mother-manifest-3a453b', + 'name': 'Pregnant Woman', + 'description': 'Select this case type to screen, consent and enroll a pregnant womans.', + 'templateCaseTitle': '${getVariable("participant_id") ? `${getVariable("firstname")} ${getVariable("surname")}` : "..."}', + 'templateCaseDescription': '${getVariable("participant_id") ? `Participant ID: ${getVariable("participant_id")} Village: ${getVariable("village")} Head of household: ${getVariable("headofhouse")}` : "..."}', + 'templateScheduleListItemIcon': '${caseEventInfo.complete ? "event_available" : "event_note"}', + 'templateScheduleListItemPrimary': '${caseEventDefinition.name}', + 'templateScheduleListItemSecondary': '${caseInstance.label}', + + 'templateCaseEventListItemIcon': '${caseEventInfo.complete ? "event_available" : "event_note"}', + 'templateCaseEventListItemPrimary': '${caseEventDefinition.name}', + 'templateCaseEventListItemSecondary': '${TRANSLATE("Scheduled")}: ${formatDate(caseEventInfo.dateStart,"dddd, MMMM Do YYYY, h:mm:ss a")}', + 'templateEventFormListItemIcon': '', + 'templateEventFormListItemPrimary': '${eventFormDefinition.name}', + 'templateEventFormListItemSecondary': 'Workflow: ${getValue("workflow_state")} Status: ${!eventForm.complete ? "Incomplete" : "Complete"}', + 'startFormOnOpen': { + 'eventId': 'c1', + 'eventFormId': 'event-form-definition-ece26e' + }, + 'eventDefinitions': [ + { + 'id': 'c1', + 'name': 'ANC-Enrollment', + 'description': '', + 'repeatable': false, + 'estimatedTimeFromCaseOpening': 0, + 'estimatedTimeWindow': 0, + 'required': true, + 'eventFormDefinitions': [ + { + 'id': 'event-form-definition-ece26e', + 'formId': 's0-participant-information-f254b9', + 'name': 'S01 - Screening and Enrollment', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-574497', + 'formId': 's02-drug-administration-99380a', + 'name': 'S02 - AZ dosing', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-c94289', + 'formId': 's03-demographic-characteristics-c09a84', + 'name': 'S03 - Demographic information', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-a5301b', + 'formId': 's04-current-pregnancy-record-and-pregnancy-history-9858d6', + 'name': 'S04 - Maternal Clinical history', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-c58ef3', + 'formId': 's05-maternal-physical-exam-1c983b', + 'name': 'S05 - Physical Exam', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-z830kj', + 'formId': 's06-current-anc-visit-68fc78', + 'name': 'S06 - Current ANC visit', + 'required': true, + 'repeatable': false + }, + { + 'id': 'event-form-definition-z452kj', + 'formId': 's07-visit-information-82328c', + 'name': 'S07 - Visit Close Out', + 'required': true, + 'repeatable': false + } + ] + }, + ] + } + return [ + { + id: 'e1', + caseId: 'response1', + caseEventDefinitionId: 'c1', + status: CASE_EVENT_STATUS_IN_PROGRESS, + eventForms: [], + estimate: false, + scheduledDay: REFERENCE_TIME, + occurredOnDay: REFERENCE_TIME, + estimatedDay: REFERENCE_TIME, + windowStartDay: REFERENCE_TIME, + windowEndDay: REFERENCE_TIME_2, + caseDefinition, + caseInstance: { + caseDefinitionId: 'c1', + type: 'case', + label: 'Pregnant Woman', + collection: 'TangyFormResponse', + events: [ + { + 'id': '0ff22322-7734-4cc1-957d-dd25416a9413', + 'caseId': 'ff621529-d26b-42cc-a9a1-254179e75622', + 'status': 'in-progress', + 'name': 'ANC-Enrollment', + 'estimate': true, + 'caseEventDefinitionId': 'c1', + 'scheduledDay': REFERENCE_TIME, + 'occurredOnDay': REFERENCE_TIME, + 'estimatedDay': REFERENCE_TIME, + 'windowStartDay': REFERENCE_TIME, + 'windowEndDay': REFERENCE_TIME_2, + 'eventForms': [ + { + 'id': 'fb64d705-ee99-4a27-bbad-8349a0e8767d', + 'complete': false, + 'caseId': 'ff621529-d26b-42cc-a9a1-254179e75622', + 'caseEventId': '0ff22322-7734-4cc1-957d-dd25416a9413', + 'eventFormDefinitionId': 'event-form-definition-ece26e', + 'formResponseId': '487517f7-0bec-4363-b22c-0c20090fe284' + }, + { + 'id': '83c502a2-8c82-4ed8-ad05-e8263cd56a0b', + 'complete': false, + 'caseId': 'ff621529-d26b-42cc-a9a1-254179e75622', + 'caseEventId': '0ff22322-7734-4cc1-957d-dd25416a9413', + 'eventFormDefinitionId': 'event-form-definition-574497', + 'formResponseId': '3cd53c20-d213-4e8b-bf90-c7091bf95ad6' + }, + ], + 'startDate': 0 + } + ] + } + } +]} +} + +describe('CasesService', () => { + + beforeEach(() => TestBed.configureTestingModule({ + imports:[HttpClientModule], + providers: [ + {provide:CasesService, useClass:MockCasesService}, + { + provide: UserService, + useClass: MockUserService + } + ] + })); + + it('should be created', () => { + const service:CasesService = TestBed.get(CasesService); + expect(service).toBeTruthy(); + }); + + it('should be give events by date', async() => { + const service:CasesService = TestBed.get(CasesService); + const result = await service.getEventsByDate(5, 15, false) + expect(result.length).toEqual(1) + }) + + it('should be give events by date with estimates excluded', async() => { + const service:CasesService = TestBed.get(CasesService); + const result = await service.getEventsByDate(5, 15, true) + expect(result.length).toEqual(1) + }) + +}); diff --git a/online-survey-app/src/app/case/services/cases.service.ts b/online-survey-app/src/app/case/services/cases.service.ts new file mode 100644 index 0000000000..3286d49955 --- /dev/null +++ b/online-survey-app/src/app/case/services/cases.service.ts @@ -0,0 +1,46 @@ +import { TangyFormService } from 'src/app/tangy-forms/tangy-form.service'; +import { Injectable } from '@angular/core'; +import { CaseEventInfo } from './case-event-info.class'; +import { CaseService } from './case.service'; +import * as moment from 'moment'; + +@Injectable({ + providedIn: 'root' +}) +export class CasesService { + + constructor( + private caseService: CaseService, + private tangyFormService:TangyFormService + ) { } + + async getEventsByDate(dateStart, dateEnd, excludeEstimates = false): Promise> { + const allResponses = await this.tangyFormService.getAllResponses() + const docs = >(allResponses) + .filter(doc => doc.collection === 'TangyFormResponse' && doc.type === 'case') + .reduce((acc, caseDoc) => [...acc, ...caseDoc.events + .map(event => { + return this.caseService.getCaseDefinitionByID(caseDoc.caseDefinitionId).then(caseDefinition => { + return { ...event, caseInstance: caseDoc, caseDefinition } + }) + })], >[]); + return Promise.all(docs). + then(data => data.filter(eventInfo => this.doesOverlap(dateStart, dateEnd, eventInfo) && !(excludeEstimates && eventInfo.estimate)) + .sort(function (a, b) { + const dateA = new Date(a.occurredOnDay || a.scheduledDay || a.estimatedDay || a.windowStartDay).getTime() + const dateB = new Date(b.occurredOnDay || b.scheduledDay || b.estimatedDay || a.windowEndDay).getTime() + return dateA - dateB; + })) + } + + private doesOverlap(dateStart, dateEnd, eventInfo: CaseEventInfo): boolean { + // Only show items on schedule view if the following dates are set on the event + if (!(eventInfo.scheduledDay || eventInfo.estimatedDay || eventInfo.windowStartDay)){ + return false; + } + dateStart = moment(new Date(dateStart)).format('YYYY-MM-DD') + dateEnd = moment(new Date(dateEnd)).format('YYYY-MM-DD') + return moment(eventInfo.scheduledDay || eventInfo.estimatedDay || eventInfo.windowStartDay).isBetween(dateStart, dateEnd, 'days', '[]') + || moment(eventInfo.scheduledDay || eventInfo.estimatedDay ||eventInfo.windowEndDay).isBetween(dateStart, dateEnd, 'days', '[]') + } +} diff --git a/online-survey-app/src/app/shared/_services/app-config.service.ts b/online-survey-app/src/app/shared/_services/app-config.service.ts index d74c53c1a9..5d11d28a1b 100644 --- a/online-survey-app/src/app/shared/_services/app-config.service.ts +++ b/online-survey-app/src/app/shared/_services/app-config.service.ts @@ -1,12 +1,35 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { AppConfig } from '../classes/app-config'; +import { Loc } from 'tangy-form/util/loc.js' + +export interface LocationNode { + id:string + level:string + label:string + data:any +} + +export interface LocationList { + locations:any + locationsLevels:Array + metadata:any +} + +export interface FlatLocationList { + locations:Array + locationsLevels:Array + metadata:any +} @Injectable({ providedIn: 'root' }) export class AppConfigService { + locationList:LocationList + flatLocationList:FlatLocationList + constructor(private httpClient: HttpClient) { } async getAppConfig(): Promise> { try { @@ -21,4 +44,10 @@ export class AppConfigService { async getAppName(): Promise{ return (await this.getAppConfig()).appName; } + + async getFlatLocationList() { + this.locationList = this.locationList || await this.httpClient.get('./assets/location-list.json').toPromise(); + this.flatLocationList = this.flatLocationList || Loc.flatten(JSON.parse(JSON.stringify(this.locationList))) + return this.flatLocationList + } } diff --git a/online-survey-app/src/app/shared/classes/user-database.class.ts b/online-survey-app/src/app/shared/classes/user-database.class.ts index 50b0d34932..38e681573c 100644 --- a/online-survey-app/src/app/shared/classes/user-database.class.ts +++ b/online-survey-app/src/app/shared/classes/user-database.class.ts @@ -1,8 +1,6 @@ -import {_TRANSLATE} from '../translation-marker'; -const SHARED_USER_DATABASE_NAME = 'shared-user-database'; -import PouchDB from 'pouchdb'; -import { DB } from '../_factories/db.factory'; -import * as jsonpatch from "fast-json-patch"; +import axios from 'axios' +import * as jsonpatch from "fast-json-patch" + export class UserDatabase { @@ -13,65 +11,35 @@ export class UserDatabase { buildId:string; buildChannel:string; groupId:string; - db: PouchDB; - attachHistoryToDocs:boolean + attachHistoryToDocs:boolean = undefined - constructor(username: string, userId: string, key:string = '', deviceId: string, shared = false, buildId = '', buildChannel = '', groupId = '', attachHistoryToDocs = false) { + constructor(userId: string, groupId = '') { this.userId = userId - this.username = username - this.name = username - this.deviceId = deviceId - this.buildId = buildId - this.buildChannel = buildChannel + this.username = userId + this.name = userId + this.deviceId = 'SURVEY' + this.buildId = 'SURVEY' + this.buildChannel = 'SURVEY' this.groupId = groupId - this.attachHistoryToDocs = attachHistoryToDocs - if (shared) { - this.db = DB(SHARED_USER_DATABASE_NAME, key) - } else { - this.db = DB(username, key) - } } - async synced(doc) { - return await this.db.put({ - ...doc, - tangerineSyncedOn: Date.now() - }); - } - - async get(_id) { - const doc = await this.db.get(_id); - // @TODO Temporary workaround for CryptoPouch bug where it doesn't include the _rev when getting a doc. - if (this.db.cryptoPouchIsEnabled) { - const tmpDb = new PouchDB(this.db.name) - const encryptedDoc = await tmpDb.get(_id) - doc._rev = encryptedDoc._rev - } - return doc + async get(id) { + const token = localStorage.getItem('token'); + return (await axios.get(`/group-responses/read/${this.groupId}/${id}`, { headers: { authorization: token }})).data } async put(doc) { - const newDoc = { - ...doc, - tangerineModifiedByUserId: this.userId, - tangerineModifiedByDeviceId: this.deviceId, - tangerineModifiedOn: Date.now(), - buildId: this.buildId, - deviceId: this.deviceId, - groupId: this.groupId, - buildChannel: this.buildChannel, - // Backwards compatibility for sync protocol 1. - lastModified: Date.now() - } - return await this.db.put({ - ...newDoc, - ...this.attachHistoryToDocs - ? { history: await this._calculateHistory(newDoc) } - : { } - }); + return await this.post(doc) } async post(doc) { + const token = localStorage.getItem('token'); + if (this.attachHistoryToDocs === undefined) { + const appConfig = (await axios.get('./assets/app-config.json', { headers: { authorization: token }})).data + this.attachHistoryToDocs = appConfig['attachHistoryToDocs'] + ? true + : false + } const newDoc = { ...doc, tangerineModifiedByUserId: this.userId, @@ -84,50 +52,32 @@ export class UserDatabase { // Backwards compatibility for sync protocol 1. lastModified: Date.now() } - return await this.db.post({ - ...newDoc, - ...this.attachHistoryToDocs - ? { history: await this._calculateHistory(newDoc) } - : { } - }); - } - - remove(doc) { - return this.db.remove(doc); - } - - query(queryName: string, options = {}) { - return this.db.query(queryName, options); - } - - destroy() { - return this.db.destroy(); - } - - changes(options) { - return this.db.changes(options); - } - - allDocs(options) { - return this.db.allDocs(options); - } - - sync(remoteDb, options) { - return this.db.sync(remoteDb, options); - } - - upsert(docId, callback) { - return this.db.upsert(docId, callback); + return (await axios.post(`/group-responses/update/${this.groupId}`, { + response: { + ...newDoc, + ...this.attachHistoryToDocs + ? { history: await this._calculateHistory(newDoc) } + : { } + } + }, + { + headers: { + authorization: token + } + } + )).data; } - compact() { - return this.db.compact(); + async remove(doc) { + // This is not implemented... + const token = localStorage.getItem('token'); + return await axios.delete(`/api/${this.groupId}`, doc) } async _calculateHistory(newDoc) { let history = [] try { - const currentDoc = await this.db.get(newDoc._id) + const currentDoc = await this.get(newDoc._id) const entry = { lastRev: currentDoc._rev, patch: jsonpatch.compare(currentDoc, newDoc).filter(mod => mod.path.substr(0,8) !== '/history') diff --git a/online-survey-app/src/app/tangy-forms/classes/form-info.class.ts b/online-survey-app/src/app/tangy-forms/classes/form-info.class.ts new file mode 100644 index 0000000000..1574ef99c6 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/classes/form-info.class.ts @@ -0,0 +1,63 @@ +import { AppContext } from './../../app-context.enum'; +export class FormInfo { + id:string + src:string + type:string + title:string + description:string = '' + listed = true + archived = false + templates: Array = [] + searchSettings:FormSearchSettings = { + shouldIndex: true, + variablesToIndex: [], + primaryTemplate: '', + secondaryTemplate: '' + } + customSyncSettings:CustomSyncSettings = { + enabled: false, + push: false, + pull: false, + excludeIncomplete:false + } + couchdbSyncSettings:CouchdbSyncSettings = { + enabled: false, + filterByLocation: false + } + formVersionId:string + formVersions:Array +} + +export interface FormVersion { + id:string + src:string +} + +export interface FormTemplate { + id:string + src:string + label:string + appContext: AppContext +} + + +export interface CouchdbSyncSettings { + enabled: boolean + push: boolean + pull: boolean + filterByLocation:boolean +} + +export interface CustomSyncSettings { + enabled: boolean + push:boolean + pull:boolean + excludeIncomplete:boolean +} + +export interface FormSearchSettings { + shouldIndex:boolean + variablesToIndex:Array + primaryTemplate:string + secondaryTemplate:string +} diff --git a/online-survey-app/src/app/tangy-forms/classes/form-version.class.ts b/online-survey-app/src/app/tangy-forms/classes/form-version.class.ts new file mode 100644 index 0000000000..ad077deea5 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/classes/form-version.class.ts @@ -0,0 +1,5 @@ +export class FormVersion { + id: string + src: string + legacyOriginal: boolean +} \ No newline at end of file diff --git a/online-survey-app/src/app/tangy-forms/safe-url.pipe.ts b/online-survey-app/src/app/tangy-forms/safe-url.pipe.ts new file mode 100755 index 0000000000..3a8240b219 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/safe-url.pipe.ts @@ -0,0 +1,13 @@ +import { DomSanitizer } from '@angular/platform-browser'; +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'safeUrl' +}) +export class SafeUrlPipe implements PipeTransform { + + constructor(private sanitizer: DomSanitizer) { } + transform(url): any { + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } +} diff --git a/online-survey-app/src/app/tangy-forms/tangy-form-response.class.ts b/online-survey-app/src/app/tangy-forms/tangy-form-response.class.ts new file mode 100644 index 0000000000..c5e319b2f4 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-form-response.class.ts @@ -0,0 +1,43 @@ +export class TangyFormResponse { + _id:string; + _rev:string; + collection = 'TangyFormResponse' + complete = false + formType = 'form' + form:TangyForm; + items:Array; + + get variablesArray() { + // Reduce to an array. + return this.items.reduce((inputsArray, item) => { + item.inputs.forEach(input => { + if (input.tagName === 'TANGY-CARDS') { + input.value.forEach(card => card.value.forEach(input => inputsArray.push(input))) + } else { + inputsArray.push(input) + } + }) + return inputsArray + }, []) + } + + get variables() { + // Reduce to an object keyed on input.name. If multiple inputs with the same name, put them in an array. + return this.variablesArray.reduce((inputsObject, input) => { + if (inputsObject.hasOwnProperty(input.name)) { + if (Array.isArray(inputsObject[input.name])) { + inputsObject[input.name] = inputsObject[input.name].push(input) + } else { + inputsObject[input.name] = [input, inputsObject[input.name]] + } + } else { + inputsObject[input.name] = input + } + return inputsObject + }, {}) + } +} + +export class TangyForm { + id:string +} \ No newline at end of file diff --git a/online-survey-app/src/app/tangy-forms/tangy-form-service.ts b/online-survey-app/src/app/tangy-forms/tangy-form-service.ts new file mode 100644 index 0000000000..189ab3690e --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-form-service.ts @@ -0,0 +1,41 @@ +import {Injectable} from '@angular/core'; +// A dummy function so TS does not complain about our use of emit in our pouchdb queries. +const emit = (key, value) => { + return true; +} + +@Injectable() +export class TangyFormService { + + db:any; + databaseName: String; + + constructor() { + this.databaseName = 'tangy-forms' + } + + // Would be nice if this was queue based so if two saves get called at the same time, the differentials are sequentials updated + // into the database. Using a getter and setter for property fields, this would be one way to queue. + async saveResponse(responseDoc) { + let r + if (!responseDoc._id) { + r = await this.db.post(responseDoc) + } + else { + r = await this.db.put(responseDoc) + } + return await this.db.get(r.id) + + } + + async getResponse(responseId) { + try { + let doc = await this.db.get(responseId) + return doc + } catch (e) { + return false + } + } + + +} diff --git a/online-survey-app/src/app/tangy-forms/tangy-form.service.ts b/online-survey-app/src/app/tangy-forms/tangy-form.service.ts new file mode 100644 index 0000000000..f4a48e1860 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-form.service.ts @@ -0,0 +1,72 @@ +import { UserDatabase } from '../shared/classes/user-database.class'; +import { HttpClient } from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {FormVersion} from "./classes/form-version.class"; +import {TangyFormsInfoService} from "./tangy-forms-info-service"; +import {FormInfo} from "./classes/form-info.class"; + +@Injectable() +export class TangyFormService { + + db:any; + databaseName: String; + groupId:string + userId:string + formsMarkup: Array = [] + constructor( + private httpClient:HttpClient, + private tangyFormsInfoService: TangyFormsInfoService + ) { + this.databaseName = 'tangy-forms' + } + + initialize(groupId) { + this.userId = localStorage.getItem('user_id') || 'Survey' + this.db = new UserDatabase(this.userId, groupId) + } + + // Would be nice if this was queue based so if two saves get called at the same time, the differentials are sequentials updated + // into the database. Using a getter and setter for property fields, this would be one way to queue. + async saveResponse(response) { + try { + const doc = await this.db.post(response) + return doc + } catch (e) { + return false + } + } + + async getResponse(responseId) { + try { + const doc = await this.db.get(responseId) + return doc + } catch (e) { + return false + } + } + + async getAllResponses() { + return [] + } + + async getResponsesByFormId(formId:string, limit:number = 99999999, skip:number = 0) { + return >await this.httpClient.get(`/api/${this.groupId}/responsesByFormId/${formId}/${limit}/${skip}`).toPromise() + /* + return Array(>await this.httpClient.get(`/api/${groupId}/responsesByFormId/${formId}/${limit}/${skip}`).toPromise()) + .map((doc) => new TangyFormResponseModel(doc)) + */ + } + + /** + * Gets markup for a form. If displaying a formResponse, populate the revision in order to display the correct form version. + * @param formId + * @param formVersionId - Uses this value to lookup the correct version to display. It is null if creating a new response. + */ + async getFormMarkup(formId, formVersionId:string = '') { + let formMarkup:any + let src: string = await this.tangyFormsInfoService.getFormSrc(formId, formVersionId) + formMarkup = await this.httpClient.get(src, {responseType: 'text'}).toPromise() + return formMarkup + } + +} diff --git a/online-survey-app/src/app/tangy-forms/tangy-forms-info-service.ts b/online-survey-app/src/app/tangy-forms/tangy-forms-info-service.ts new file mode 100644 index 0000000000..787cc6bff0 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-forms-info-service.ts @@ -0,0 +1,38 @@ +import { FormInfo } from './classes/form-info.class'; +import { Inject, Injectable } from '@angular/core'; +import {HttpClient} from '@angular/common/http'; + +@Injectable({ + providedIn: 'root' +}) +export class TangyFormsInfoService { + formsInfo: Array + formsMarkup: Array = [] + constructor( + private http: HttpClient + ) { } + + async getFormsInfo() { + this.formsInfo = this.formsInfo ? this.formsInfo : >await this.http.get('./assets/forms.json').toPromise() + return this.formsInfo + } + + async getFormInfo(id:string):Promise { + return (await this.getFormsInfo()).find(formInfo => formInfo.id === id) + } + + async getFormTemplateMarkup(formId:string, formTemplateId:string):Promise { + const formInfo = await this.getFormInfo(formId) + const formTemplate = formInfo.templates.find(formTemplate => formTemplate.id === formTemplateId) + const formTemplateMarkup = await this.http.get(formTemplate.src, { responseType: 'text' }).toPromise() + return formTemplateMarkup + } + + async getFormSrc(formId, formVersionId:string = '') { + const formInfo = await this.getFormInfo(formId) + return formVersionId + ? formInfo.formVersions.find(formVersion => formVersion.id === formVersionId).src + : formInfo.src + } + +} diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.css b/online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.css similarity index 100% rename from online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.css rename to online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.css diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.html b/online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.html similarity index 100% rename from online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.html rename to online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.html diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.spec.ts b/online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.spec.ts similarity index 100% rename from online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.spec.ts rename to online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.spec.ts diff --git a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts b/online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.ts similarity index 65% rename from online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts rename to online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.ts index 4b2183bb69..6c599b0308 100644 --- a/online-survey-app/src/app/tangy-forms-player/tangy-forms-player.component.ts +++ b/online-survey-app/src/app/tangy-forms/tangy-forms-player/tangy-forms-player.component.ts @@ -1,7 +1,9 @@ import { HttpClient } from '@angular/common/http'; import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { FormsService } from '../shared/_services/forms-service.service'; +import { FormsService } from 'src/app/shared/_services/forms-service.service'; +import { CaseService } from 'src/app/case/services/case.service'; +import { TangyFormService } from '../tangy-form.service'; @Component({ selector: 'app-tangy-forms-player', @@ -16,34 +18,39 @@ export class TangyFormsPlayerComponent implements OnInit { caseId: string; caseEventId: string; eventFormId: string; + window: any; - constructor(private route: ActivatedRoute, private formsService: FormsService, private router: Router, private httpClient:HttpClient + constructor( + private route: ActivatedRoute, + private formsService: FormsService, + private router: Router, + private httpClient:HttpClient, + private caseService: CaseService, + private tangyFormService: TangyFormService ) { this.router.events.subscribe(async (event) => { this.formId = this.route.snapshot.paramMap.get('formId'); this.formResponseId = this.route.snapshot.paramMap.get('formResponseId'); - this.caseId = this.route.snapshot.paramMap.get('caseId'); - this.caseEventId = this.route.snapshot.paramMap.get('caseEventId'); - this.eventFormId = this.route.snapshot.paramMap.get('eventFormId'); + this.caseId = this.route.snapshot.paramMap.get('case'); + this.caseEventId = this.route.snapshot.paramMap.get('event'); + this.eventFormId = this.route.snapshot.paramMap.get('form'); }); } async ngOnInit(): Promise { - const data = await this.httpClient.get('./assets/form/form.html', {responseType: 'text'}).toPromise(); - this.container.nativeElement.innerHTML = data; - let tangyForm = this.container.nativeElement.querySelector('tangy-form'); + this.tangyFormService.initialize(window.location.pathname.split('/')[4]); + this.window = window; + this.window.T = { + case: this.caseService, + tangyForms: this.tangyFormService + } + let formResponse; if (this.caseId) { try { - const caseDoc = await this.formsService.getFormResponse(this.caseId); - if (caseDoc) { - let inputs = caseDoc.items[0].inputs; - if (inputs.length > 0) { - window.localStorage.setItem('caseVariables', JSON.stringify(inputs)); - } - } + await this.caseService.load(this.caseId); } catch (error) { - console.log('Error loading case variables: ' + error) + console.log('Error loading case: ' + error) } if (this.eventFormId) { @@ -51,7 +58,7 @@ export class TangyFormsPlayerComponent implements OnInit { // Attempt to load the form response for the event form const eventForm = await this.formsService.getEventFormData(this.eventFormId); if (eventForm && eventForm.formResponseId) { - tangyForm.response = await this.formsService.getFormResponse(eventForm.formResponseId); + formResponse = await this.formsService.getFormResponse(eventForm.formResponseId); } } catch (error) { //pass @@ -59,6 +66,14 @@ export class TangyFormsPlayerComponent implements OnInit { } } + const data = await this.httpClient.get('./assets/form/form.html', {responseType: 'text'}).toPromise(); + this.container.nativeElement.innerHTML = data; + let tangyForm = this.container.nativeElement.querySelector('tangy-form'); + + if (formResponse) { + tangyForm.response = formResponse; + } + tangyForm.addEventListener('after-submit', async (event) => { event.preventDefault(); try { diff --git a/online-survey-app/src/app/tangy-forms/tangy-forms-routing.module.ts b/online-survey-app/src/app/tangy-forms/tangy-forms-routing.module.ts new file mode 100755 index 0000000000..4bcb8e4c79 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-forms-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; + +const routes: Routes = [{ + path: 'tangy-form-player', + component: TangyFormsPlayerComponent +} +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class TangyFormsRoutingModule { } diff --git a/online-survey-app/src/app/tangy-forms/tangy-forms.module.ts b/online-survey-app/src/app/tangy-forms/tangy-forms.module.ts new file mode 100755 index 0000000000..fd498a5b74 --- /dev/null +++ b/online-survey-app/src/app/tangy-forms/tangy-forms.module.ts @@ -0,0 +1,32 @@ +import { TangyFormsInfoService } from './tangy-forms-info-service'; +import { TangyFormService } from './tangy-form.service'; +import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { TangyFormsRoutingModule } from './tangy-forms-routing.module'; +import { TangyFormsPlayerComponent } from './tangy-forms-player/tangy-forms-player.component'; +import { MatButtonModule } from "@angular/material/button"; +import { MatChipsModule } from '@angular/material/chips'; +import { MatMenuModule } from "@angular/material/menu"; +import { MatTabsModule } from "@angular/material/tabs"; +import {SharedModule} from "../shared/shared.module"; + +@NgModule({ + schemas: [CUSTOM_ELEMENTS_SCHEMA], + exports: [TangyFormsPlayerComponent], + imports: [ + CommonModule, + MatTabsModule, + MatMenuModule, + MatButtonModule, + MatChipsModule, + SharedModule, + TangyFormsRoutingModule + ], + providers: [ + TangyFormsInfoService, + TangyFormService + ], + declarations: [TangyFormsPlayerComponent] +}) +export class TangyFormsModule { } diff --git a/server/src/scripts/release-online-survey-app.sh b/server/src/scripts/release-online-survey-app.sh index 2cce11623b..18d269db4a 100755 --- a/server/src/scripts/release-online-survey-app.sh +++ b/server/src/scripts/release-online-survey-app.sh @@ -33,7 +33,7 @@ mkdir -p $GROUP_DIRECTORY RELEASE_DIRECTORY="$GROUP_DIRECTORY/$FORM_ID" rm -r $RELEASE_DIRECTORY -FORM_CLIENT_DIRECTORY="/tangerine/groups/$GROUP_ID/client/" +FORM_CLIENT_DIRECTORY="/tangerine/groups/$GROUP_ID/client" FORM_DIRECTORY="$FORM_CLIENT_DIRECTORY/$FORM_ID" LOCATION_LIST_PATH="$FORM_CLIENT_DIRECTORY/location-list.json" LOCATION_LISTS_DIRECTORY="$FORM_CLIENT_DIRECTORY/locations" @@ -45,6 +45,8 @@ CUSTOM_SCRIPTS_MAP="$FORM_CLIENT_DIRECTORY/custom-scripts.js.map" cp -r /tangerine/online-survey-app/dist/online-survey-app/ $RELEASE_DIRECTORY # Ensure the release directories exists +mkdir -p $RELEASE_DIRECTORY/assets +mkdir -p $RELEASE_DIRECTORY/assets/form mkdir -p $RELEASE_DIRECTORY/assets/locations mkdir -p $RELEASE_DIRECTORY/assets/media @@ -57,6 +59,25 @@ cp /tangerine/translations/*.json $RELEASE_DIRECTORY/assets/ cp $CUSTOM_SCRIPTS $RELEASE_DIRECTORY/assets/ cp $CUSTOM_SCRIPTS_MAP $RELEASE_DIRECTORY/assets/ +CASE_DEFINITIONS="$FORM_CLIENT_DIRECTORY/case-definitions.json" +echo "CASE_DEFINITIONS: $CASE_DEFINITIONS" +if [ -f "$CASE_DEFINITIONS" ]; then + echo "Found case definitions" + cp $CASE_DEFINITIONS $RELEASE_DIRECTORY/assets/ + # read the case definitions and add the files to the release directory + while IFS= read -r line + do + # if the line contains "src" then it is a file to copy + if [[ $line != *"src"* ]]; then + continue + fi + # line is like: "src": "./assets/parent-of-child-6-7-case.json" + # strip the file name + line=$(echo $line | sed 's/"src": ".\/assets\///g' | sed 's/"//g') + cp $FORM_CLIENT_DIRECTORY/$line $RELEASE_DIRECTORY/assets/ + done < $CASE_DEFINITIONS +fi + FORM_UPLOAD_URL="/onlineSurvey/saveResponse/$GROUP_ID/$FORM_ID" if [[ $REQUIRE_ACCESS_CODE == 'true' ]]; then From 69a510846a072bb312652832cd67f6027b1801ef Mon Sep 17 00:00:00 2001 From: esurface Date: Tue, 4 Jun 2024 13:56:12 -0400 Subject: [PATCH 13/42] Allow online survey link for case event form; online-survey custom-login-markup --- .../classes/event-form-definition.class.ts | 1 + .../event-form-list-item.component.html | 3 +++ .../event-form-list-item.component.ts | 15 ++++++++++++-- .../survey-login/survey-login.component.html | 5 ++--- .../survey-login/survey-login.component.ts | 20 +++++++++++++++++-- .../auth/_services/authentication.service.ts | 5 ++++- server/src/auth-utils.js | 10 ---------- server/src/online-survey.js | 7 ++++--- .../src/scripts/release-online-survey-app.sh | 2 ++ 9 files changed, 47 insertions(+), 21 deletions(-) diff --git a/editor/src/app/case/classes/event-form-definition.class.ts b/editor/src/app/case/classes/event-form-definition.class.ts index 1b0782fddd..31904304bf 100644 --- a/editor/src/app/case/classes/event-form-definition.class.ts +++ b/editor/src/app/case/classes/event-form-definition.class.ts @@ -15,6 +15,7 @@ export class EventFormDefinition { allowDeleteIfFormNotStarted:string onEventFormOpen?:string onEventFormClose?:string + allowOnline?:boolean constructor() { } diff --git a/editor/src/app/case/components/event-form-list-item/event-form-list-item.component.html b/editor/src/app/case/components/event-form-list-item/event-form-list-item.component.html index 60a43e393d..bbe69c67fa 100644 --- a/editor/src/app/case/components/event-form-list-item/event-form-list-item.component.html +++ b/editor/src/app/case/components/event-form-list-item/event-form-list-item.component.html @@ -5,6 +5,9 @@
+ + link + + + + + +