diff --git a/.env.example b/.env.example index 335fb2b2..d13889bc 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ POSTGRES_DB=amos # Full database connection URL constructed from the above PostgreSQL variables used for Prisma DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +# The Api Url for the Insight Hub API +INSIGHT_HUB_API_URL=https://gateway.eu1.mindsphere.io/api +# The Api Key for the Insight Hub API +INSIGHT_HUB_API_KEY=KEY + # Frontend XD_API_URL=http://${BACKEND_HOST}:${BACKEND_PORT} SECRET_KEY=SecretKey diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index afe8beb5..3e9a23b4 100644 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -1,3 +1,4 @@ +import { XdMetricsModule } from '@frontend/facilities/backend/metrics'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { XdCaseManagementModule } from 'cases-backend-case-management'; @@ -24,6 +25,7 @@ import { validateConfig } from './config/validation'; XdTimeseriesModule, XdCaseManagementModule, XdFacilitiesBackendFacilitiesModule, + XdMetricsModule ], controllers: [], providers: [], diff --git a/apps/backend/src/app/config/classes/environment.class.ts b/apps/backend/src/app/config/classes/environment.class.ts index 387761b4..c274f638 100644 --- a/apps/backend/src/app/config/classes/environment.class.ts +++ b/apps/backend/src/app/config/classes/environment.class.ts @@ -82,7 +82,7 @@ export class EnvironmentVariables implements IEnvironmentVariables { /** * The URL of the API to use for the IotTimeSeriesService */ - @IsDefined() + @IsOptional() @IsString() @MinLength(1) INSIGHT_HUB_API_URL?: string; @@ -90,7 +90,7 @@ export class EnvironmentVariables implements IEnvironmentVariables { /** * The API key to use for the IotTimeSeriesService */ - @IsDefined() + @IsOptional() @IsString() @MinLength(1) INSIGHT_HUB_API_KEY?: string; diff --git a/apps/frontend/src/app/components/header/header.component.spec.ts b/apps/frontend/src/app/components/header/header.component.spec.ts index 87612459..cc0359f6 100644 --- a/apps/frontend/src/app/components/header/header.component.spec.ts +++ b/apps/frontend/src/app/components/header/header.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, NavigationEnd, Router, RouterEvent } from '@angular/router'; +import { AuthenticationService, LocalStorageService } from 'common-frontend-models'; import { ReplaySubject } from 'rxjs'; import { HeaderComponent } from './header.component'; @@ -12,73 +13,100 @@ const HEADER_ROUTES = { } }, }, - root: { - snapshot: { - data: { - breadcrumb: 'Layer 1', - }, - }, - firstChild: { - snapshot: { - data: { - breadcrumb: 'Layer 2', - }, - }, - }, - }, + root: { + snapshot: { + data: { + breadcrumb: 'Layer 1', + }, + }, + firstChild: { + snapshot: { + data: { + breadcrumb: 'Layer 2', + }, + }, + }, + }, }; describe('HeaderComponent', () => { - let component: HeaderComponent; - let fixture: ComponentFixture; - const eventsSubject = new ReplaySubject(1); - let routerMock: Router; - - beforeEach(async () => { - routerMock = { - events: eventsSubject.asObservable(), + let component: HeaderComponent; + let fixture: ComponentFixture; + const eventsSubject = new ReplaySubject(1); + let routerMock: Router; + let localStorageServiceMock: LocalStorageService; + let authenticationServiceMock: AuthenticationService; + + beforeEach(async () => { + routerMock = { + events: eventsSubject.asObservable(), url: '/Layer1/Layer2', - } as unknown as Router; - - await TestBed.configureTestingModule({ - imports: [ HeaderComponent ], - providers: [ - { - provide: ActivatedRoute, - useValue: HEADER_ROUTES, - }, - { - provide: Router, - useValue: routerMock, - }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(HeaderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should trigger router events correctly', () => { - eventsSubject.next(new NavigationEnd(1, '', '')); - - const routerEvents = component.routerEvents(); - expect(routerEvents).toEqual({ - id: 1, - type: 1, - url: '', - urlAfterRedirects: '', - } as NavigationEnd); - }); - - it('should compute breadcrumbs correctly', () => { - eventsSubject.next(new NavigationEnd(1, '', '')); - - const breadcrumbs = component.breadcrumbs(); - expect(breadcrumbs).toEqual([ 'Layer 1', 'Layer 2' ]); - }); + navigate: jest.fn() + } as unknown as Router; + + localStorageServiceMock = { + getOrCreate: jest.fn().mockReturnValue(jest.fn().mockReturnValue('theme-classic-dark')), + set: jest.fn() + } as unknown as LocalStorageService; + + authenticationServiceMock = { + getUserMail: jest.fn().mockReturnValue('test@example.com'), + logout: jest.fn() + } as unknown as AuthenticationService; + + await TestBed.configureTestingModule({ + imports: [ HeaderComponent ], + providers: [ + { provide: ActivatedRoute, useValue: HEADER_ROUTES }, + { provide: Router, useValue: routerMock }, + { provide: LocalStorageService, useValue: localStorageServiceMock }, + { provide: AuthenticationService, useValue: authenticationServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should trigger router events correctly', () => { + eventsSubject.next(new NavigationEnd(1, '', '')); + + const routerEvents = component.routerEvents(); + expect(routerEvents).toBeInstanceOf(NavigationEnd); + }); + + it('should compute breadcrumbs correctly', () => { + eventsSubject.next(new NavigationEnd(1, '', '')); + + const breadcrumbs = component.breadcrumbs(); + expect(breadcrumbs).toEqual([ 'Layer 1', 'Layer 2' ]); + }); + + it('should determine if it is home page correctly', () => { + eventsSubject.next(new NavigationEnd(1, '', '')); + + const isHomePage = component.isHomePage(); + expect(isHomePage).toBe(true); + }); + + it('should toggle theme mode', () => { + component.toggleMode(); + expect(localStorageServiceMock.set).toHaveBeenCalledWith('theme', 'theme-classic-light'); + }); + + it('should logout and navigate to login', () => { + component.logout(); + expect(authenticationServiceMock.logout).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith([ '/account/login' ]); + }); + + it('should cut URL correctly', () => { + const cutUrl = component.cutUrl(1); + expect(cutUrl).toBe('/Layer1'); + }); }); diff --git a/libs/account/frontend/view/src/lib/components/login/login.page.spec.ts b/libs/account/frontend/view/src/lib/components/login/login.page.spec.ts index 2d7abca2..8186af2f 100644 --- a/libs/account/frontend/view/src/lib/components/login/login.page.spec.ts +++ b/libs/account/frontend/view/src/lib/components/login/login.page.spec.ts @@ -1,22 +1,57 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { AuthenticationService } from 'common-frontend-models'; +import { of } from 'rxjs'; import { LoginPage } from './login.page'; -describe('LoginComponent', () => { - let component: LoginPage; - let fixture: ComponentFixture; +describe('LoginPage', () => { + let component: LoginPage; + let fixture: ComponentFixture; + let authServiceMock: jest.Mocked; + let routerMock: jest.Mocked; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ LoginPage ], - }).compileComponents(); + beforeEach(async () => { + authServiceMock = { + login: jest.fn().mockReturnValue(of(true)), + } as unknown as jest.Mocked; - fixture = TestBed.createComponent(LoginPage); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + routerMock = { + navigate: jest.fn(), + } as unknown as jest.Mocked; - it('should create', () => { - expect(component).toBeTruthy(); - }); + await TestBed.configureTestingModule({ + imports: [ LoginPage ], + providers: [ + { provide: AuthenticationService, useValue: authServiceMock }, + { provide: Router, useValue: routerMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('onSubmit', () => { + it('should not validate form if email or password is invalid', () => { + component.email = 'invalid-email'; + component.password = ''; + component.onSubmit(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should validate form and login successfully', () => { + component.email = 'test@example.com'; + component.password = 'password'; + component.onSubmit(); + + expect(routerMock.navigate).toHaveBeenCalledWith([ '/' ]); + }); + }); }); diff --git a/libs/account/frontend/view/src/lib/components/login/login.page.ts b/libs/account/frontend/view/src/lib/components/login/login.page.ts index 91e512ec..838e5902 100644 --- a/libs/account/frontend/view/src/lib/components/login/login.page.ts +++ b/libs/account/frontend/view/src/lib/components/login/login.page.ts @@ -15,8 +15,8 @@ import { AuthenticationService } from 'common-frontend-models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class LoginPage { - protected email = ''; - protected password = ''; + public email = ''; + public password = ''; protected formValid = signal(false); protected loginSuccess = signal(false); diff --git a/libs/cases/backend/case-management/project.json b/libs/cases/backend/case-management/project.json index 2fa89756..d393701b 100644 --- a/libs/cases/backend/case-management/project.json +++ b/libs/cases/backend/case-management/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/cases/backend/case-management/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.spec.ts b/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.spec.ts index 1e854d14..be7d88b7 100644 --- a/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.spec.ts +++ b/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.spec.ts @@ -13,6 +13,9 @@ describe('XdCasesFacade', () => { const timeseriesRequestServiceMock = { getTimeSeries: jest.fn().mockReturnValue(of({})), getAllCases: jest.fn().mockReturnValue(of([])), + createCase: jest.fn().mockReturnValue(of({})), + updateCase: jest.fn().mockReturnValue(of({})), + deleteCase: jest.fn().mockReturnValue(of({})), }; TestBed.configureTestingModule({ @@ -49,4 +52,35 @@ describe('XdCasesFacade', () => { expect(response).toEqual({}); }); }); + + describe('createCase', () => { + it('should call createCase of CasesRequestService with correct parameters', async () => { + const body = { id: 1 } as any; + + await firstValueFrom(facade.createCase(body)); + + expect(casesRequestService.createCase).toHaveBeenCalledWith(body); + }); + }); + + describe('updateCase', () => { + it('should call updateCase of CasesRequestService with correct parameters', async () => { + const params: ICaseParams = { id: 1 } as ICaseParams; + const body = { id: 1 } as any; + + await firstValueFrom(facade.updateCase(params, body)); + + expect(casesRequestService.updateCase).toHaveBeenCalledWith(params, body); + }); + }); + + describe('deleteCase', () => { + it('should call deleteCase of CasesRequestService with correct parameters', async () => { + const params: ICaseParams = { id: 1 } as ICaseParams; + + await firstValueFrom(facade.deleteCase(params)); + + expect(casesRequestService.deleteCase).toHaveBeenCalledWith(params); + }); + }); }); diff --git a/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.ts b/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.ts index 255c061e..15794658 100644 --- a/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.ts +++ b/libs/cases/frontend/domain/src/lib/application/facades/cases.facade.ts @@ -27,15 +27,31 @@ export class XdCasesFacade { return this._scanService.getTimeSeries(params); } - public createCase(body: ICreateCaseBody) { + /** + * Creates a new Case + * + * @param body + */ + public createCase(body: ICreateCaseBody): Observable { return this._scanService.createCase(body); } - public updateCase(params: ICaseParams, body: ICreateCaseBody) { - return this._scanService.updateCase(params,body); - } + /** + * Updates an existing Case + * + * @param params + * @param body + */ + public updateCase(params: ICaseParams, body: ICreateCaseBody): Observable { + return this._scanService.updateCase(params, body); + } - public deleteCase(params: ICaseParams){ - return this._scanService.deleteCase(params); - } + /** + * Deletes an undesired Case + * + * @param params + */ + public deleteCase(params: ICaseParams): Observable { + return this._scanService.deleteCase(params); + } } diff --git a/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.spec.ts b/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.spec.ts index dae313cd..daa26fc3 100644 --- a/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.spec.ts +++ b/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.spec.ts @@ -20,6 +20,9 @@ describe('XdCasesRequestService', () => { provide: HttpClient, useValue: { get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), }, }, ], @@ -55,4 +58,47 @@ describe('XdCasesRequestService', () => { expect(result).toEqual(mockResponse); }); }); + + describe('createCase', () => { + it('should forward the request to the backend', async () => { + const body = { id: faker.number.int() } as any; + const mockResponse = { id: faker.number.int() } as ICaseResponse; + + const spy = jest.spyOn(httpClient, 'post').mockReturnValue(of(mockResponse)); + + const result = await firstValueFrom(service.createCase(body)); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('/api/case', body); + expect(result).toEqual(mockResponse); + }); + }); + + describe('updateCase', () => { + it('should forward the request to the backend', async () => { + const params = { id: faker.number.int() } as ICaseParams; + const body = { id: faker.number.int() } as any; + const mockResponse = { id: faker.number.int() } as ICaseResponse; + + const spy = jest.spyOn(httpClient, 'put').mockReturnValue(of(mockResponse)); + + const result = await firstValueFrom(service.updateCase(params, body)); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`/api/case/${params.id}`, body); + expect(result).toEqual(mockResponse); + }); + }); + + describe('deleteCase', () => { + it('should forward the request to the backend', async () => { + const params = { id: faker.number.int() } as ICaseParams; + const mockResponse = { id: faker.number.int() } as ICaseResponse; + + const spy = jest.spyOn(httpClient, 'delete').mockReturnValue(of(mockResponse)); + + const result = await firstValueFrom(service.deleteCase(params)); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`/api/case/${params.id}`); + expect(result).toEqual(mockResponse); + }); + }); }); diff --git a/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.ts b/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.ts index dfddc0bb..bf15c96a 100644 --- a/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.ts +++ b/libs/cases/frontend/domain/src/lib/infrastructure/cases-request.service.ts @@ -34,6 +34,11 @@ export class XdCasesRequestService { return this._httpClient.post('/api/case', body); } + /** + * + * @param params + * @param body + */ public updateCase(params: ICaseParams, body: ICreateCaseBody) { return this._httpClient.put(`/api/case/${params.id}`, body); } diff --git a/libs/cases/frontend/view/.eslintrc.json b/libs/cases/frontend/view/.eslintrc.json index 11f410b8..92bb82b4 100644 --- a/libs/cases/frontend/view/.eslintrc.json +++ b/libs/cases/frontend/view/.eslintrc.json @@ -24,6 +24,12 @@ "prefix": "lib", "style": "kebab-case" } + ], + "@angular-eslint/component-class-suffix": [ + "error", + { + "suffixes": ["Component", "Page", "Layout", "Accessor"] + } ] } }, diff --git a/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.spec.ts b/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.spec.ts index 9c520134..f7ce1728 100644 --- a/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.spec.ts +++ b/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.spec.ts @@ -1,22 +1,40 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { XdCasesFacade } from '@frontend/cases/frontend/domain'; +import { ECasePriority, ECaseStatus, ECaseType, ICaseResponse } from 'cases-shared-models'; +import { LocalStorageService } from 'common-frontend-models'; +import { of } from 'rxjs'; import { CaseBrowseComponent } from './case-browse.component'; describe('CaseBrowsComponent', () => { let component: CaseBrowseComponent; let fixture: ComponentFixture; + let mockCasesFacade: XdCasesFacade; beforeEach(async () => { + mockCasesFacade = { + getAllCases: jest.fn().mockReturnValue(of([])), + } as unknown as XdCasesFacade; + await TestBed.configureTestingModule({ imports: [ CaseBrowseComponent, HttpClientTestingModule ], - providers: [ - { - provide: ActivatedRoute, - useValue: {}, - } - ], + providers: [ + { + provide: ActivatedRoute, + useValue: {}, + }, + { + provide: LocalStorageService, + useValue: { + getOrCreate: jest.fn().mockReturnValue(signal('Status,Equal,OPEN')), + set: jest.fn(), + }, + }, + { provide: XdCasesFacade, useValue: mockCasesFacade }, + ], }).compileComponents(); fixture = TestBed.createComponent(CaseBrowseComponent); @@ -27,4 +45,34 @@ describe('CaseBrowsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('filterList', () => { + it('should filter cases based on local storage filter', () => { + const cases: ICaseResponse[] = [ + { + id: 1, + status: ECaseStatus.CANCELLED, + priority: ECasePriority.EMERGENCY, + type: ECaseType.ANNOTATION, + } as ICaseResponse, + { + id: 2, + status: ECaseStatus.OPEN, + priority: ECasePriority.EMERGENCY, + type: ECaseType.ANNOTATION, + } as ICaseResponse, + ]; + jest.spyOn(mockCasesFacade, 'getAllCases').mockReturnValue(of(cases)); + fixture.detectChanges(); + + const processedCases = component['processedCases'](); + expect(processedCases).toEqual([]); + }); + }); + + describe('getAllCases', () => { + it('should call getAllCases from the facade', () => { + expect(mockCasesFacade.getAllCases).toHaveBeenCalled(); + }); + }); }); diff --git a/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.ts b/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.ts index 9d7edb77..4e57ff96 100644 --- a/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.ts +++ b/libs/cases/frontend/view/src/lib/components/case-browse/case-browse.component.ts @@ -10,8 +10,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterLink } from '@angular/router'; import { XdCasesFacade } from '@frontend/cases/frontend/domain'; import { FilterState, IxCategoryFilterCustomEvent, IxModule } from '@siemens/ix-angular'; -import { ICaseResponse } from 'cases-shared-models'; +import { ECasePriority, ECaseStatus, ECaseType, ICaseResponse } from 'cases-shared-models'; import { LocalStorageService } from 'common-frontend-models'; +import { $enum } from 'ts-enum-util'; @Component({ selector: 'lib-brows-cases', @@ -60,9 +61,9 @@ export class CaseBrowseComponent { return filteredCases; }); - private readonly statusOptions= [ 'OPEN', 'INPROGRESS', 'OVERDUE', 'ONHOLD', 'DONE', 'CANCELLED', 'ARCHIVED' ]; - private readonly priorityOptions= [ 'EMERGENCY', 'HIGH', 'MEDIUM', 'LOW' ]; - private readonly typeOptions = [ 'PLANNED', 'INCIDENT', 'ANNOTATION' ] + private readonly statusOptions= $enum(ECaseStatus).getValues(); + private readonly priorityOptions= $enum(ECasePriority).getValues(); + private readonly typeOptions = $enum(ECaseType).getValues(); protected readonly repeatCategories = true; protected filterState = { diff --git a/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.spec.ts b/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.spec.ts new file mode 100644 index 00000000..d63286b4 --- /dev/null +++ b/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.spec.ts @@ -0,0 +1,63 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DateDropdownAccessor } from './date-dropdown-accessor'; + +describe('DateDropdownWrapperComponent', () => { + let component: DateDropdownAccessor; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ DateDropdownAccessor ], + }).compileComponents(); + + fixture = TestBed.createComponent(DateDropdownAccessor); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should write value', () => { + const value = '2024-07-10'; + component.writeValue(value); + expect(component.value).toBe(value); + }); + + it('should register onChange function', () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + expect(component.onChange).toBe(onChange); + }); + + it('should register onTouched function', () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + expect(component.onTouched).toBe(onTouched); + }); + + it('should call onChange when date changes', () => { + const mockEvent = { + detail: { + from: '10-07-2024', + }, + }; + + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.onDateChange(mockEvent); + + const expectedValue = '2024-07-10'; + expect(component.value).toBe(expectedValue); + expect(onChange).toHaveBeenCalledWith(expectedValue); + }); + + it('should convert date correctly', () => { + const date = '10-07-2024'; + const convertedDate = component.convertDate(date); + expect(convertedDate).toBe('2024-07-10'); + }); +}); diff --git a/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.ts b/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.ts new file mode 100644 index 00000000..a51d6ae7 --- /dev/null +++ b/libs/cases/frontend/view/src/lib/components/create-case/accessor/date-dropdown-accessor.ts @@ -0,0 +1,58 @@ +import { Component, forwardRef, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IxDateDropdown } from '@siemens/ix-angular'; +import { IxModule } from '@siemens/ix-angular'; + +/** + * This Value Accessor is needed to access the value of the date dropdown in the form + */ +@Component({ + selector: 'lib-date-dropdown-wrapper', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateDropdownAccessor), + multi: true, + }, + ], + standalone: true, + template: ` + + `, + imports: [ IxModule, FormsModule ], +}) +export class DateDropdownAccessor implements ControlValueAccessor { + @ViewChild(IxDateDropdown) private ixDateDropdown: IxDateDropdown; + + value: string; + onChange: (value: string) => NonNullable; + onTouched: () => NonNullable; + + writeValue(value: any): void { + this.value = value; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + onDateChange(event: any): void { + const dateString = this.convertDate(event.detail.from); + const date = new Date(dateString); + this.value = date.toISOString().slice(0, 10); + this.onChange(this.value); + } + + convertDate(date: string): string { + const [ day, month, year ] = date.split('-'); + return `${year}-${month}-${day}`; + } +} diff --git a/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.spec.ts b/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.spec.ts index 050ef0cf..193e1fe1 100644 --- a/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.spec.ts +++ b/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.spec.ts @@ -1,26 +1,101 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, NgForm } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { XdCasesFacade } from '@frontend/cases/frontend/domain'; +import { XdBrowseFacade } from '@frontend/facilities/frontend/domain'; +import { ToastService } from '@siemens/ix-angular'; +import { of } from 'rxjs'; import { CreateCaseComponent } from './create-case.component'; + describe('CreateCaseComponent', () => { - let component: CreateCaseComponent; - let fixture: ComponentFixture; + let component: CreateCaseComponent; + let fixture: ComponentFixture; + let toastService: ToastService; + let casesFacade: XdCasesFacade; + + beforeEach(async () => { + const toastServiceMock = { + show: jest.fn().mockResolvedValue(true) + }; + + const casesFacadeMock = { + createCase: jest.fn().mockReturnValue(of({})) + }; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ CreateCaseComponent, HttpClientTestingModule ], + const browseFacadeMock = { + getAllFacilities: jest.fn().mockReturnValue(of([])) + }; + + await TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule, FormsModule, CreateCaseComponent ], providers: [ - { provide: ActivatedRoute, useValue: { snapshot: { params: ''}}, }, + { provide: ActivatedRoute, useValue: { snapshot: { params: { facilityId: '1' } } } }, + { provide: ToastService, useValue: toastServiceMock }, + { provide: XdCasesFacade, useValue: casesFacadeMock }, + { provide: XdBrowseFacade, useValue: browseFacadeMock } ] - }).compileComponents(); + }).compileComponents(); + + fixture = TestBed.createComponent(CreateCaseComponent); + component = fixture.componentInstance; + toastService = TestBed.inject(ToastService); + casesFacade = TestBed.inject(XdCasesFacade); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Initial state', () => { + it('should initialize with facilityId from route params', () => { + expect(component.createCaseForm.selectFacility).toBe('1'); + }); + + it('should not submit form if form is invalid', () => { + const form = { + valid: false, + form: { + value: {} + }, + reset: jest.fn() + } as unknown as NgForm; + + component.onSubmit(form); + + expect(component.wasValidated).toBe(true); + expect(casesFacade.createCase).not.toHaveBeenCalled(); + expect(toastService.show).not.toHaveBeenCalled(); + }); + }); + + describe('onFacilityInputChange', () => { + it('should update facility placeholder on facility input change', () => { + const event = { detail: 'New Facility' } as CustomEvent; + component.onFacilityInputChange(event); + expect(component.facilityPlaceholder()).toBe('New Facility'); + expect(component.createCaseForm.selectFacility).toBe(''); + }); + }); - fixture = TestBed.createComponent(CreateCaseComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + describe('onTypeInputChange', () => { + it('should update type placeholder on type input change', () => { + const event = { detail: 'New Type' } as CustomEvent; + component.onTypeInputChange(event); + expect(component.typePlaceholder()).toBe('New Type'); + expect(component.createCaseForm.selectType).toBe(''); + }); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + describe('onPriorityInputChange', () => { + it('should update priority placeholder on priority input change', () => { + const event = { detail: 'New Priority' } as CustomEvent; + component.onPriorityInputChange(event); + expect(component.priorityPlaceholder()).toBe('New Priority'); + expect(component.createCaseForm.selectPriority).toBe(''); + }); + }); }); diff --git a/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.ts b/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.ts index 13b0282e..a056c7c7 100644 --- a/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.ts +++ b/libs/cases/frontend/view/src/lib/components/create-case/create-case.component.ts @@ -13,16 +13,16 @@ import { IxModule, IxSelectCustomEvent, ToastService } from '@siemens/ix-angular import { ECasePriority, ECaseStatus, ECaseType } from 'cases-shared-models'; import { CaseFormData } from '../interfaces/case-form-data.interface'; -import { DateDropdownWrapperComponent } from './date-dropdown-accessor'; +import { DateDropdownAccessor } from './accessor/date-dropdown-accessor'; @Component({ selector: 'lib-create-case', standalone: true, - imports: [ CommonModule, IxModule, FormsModule, RouterLink, DateDropdownWrapperComponent ], + imports: [ CommonModule, IxModule, FormsModule, RouterLink, DateDropdownAccessor ], providers: [ { provide: NG_VALUE_ACCESSOR, - useClass: DateDropdownWrapperComponent, + useClass: DateDropdownAccessor, multi: true, }, ], diff --git a/libs/cases/frontend/view/src/lib/components/create-case/date-dropdown-accessor.ts b/libs/cases/frontend/view/src/lib/components/create-case/date-dropdown-accessor.ts deleted file mode 100644 index 724d8ac9..00000000 --- a/libs/cases/frontend/view/src/lib/components/create-case/date-dropdown-accessor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Component, forwardRef, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { IxDateDropdown } from '@siemens/ix-angular'; // Pfad zur externen Bibliothek -import { IxModule } from '@siemens/ix-angular'; // Pfad zur externen Bibliothek -/** - * This Value Acessor is needed to acess the value of the date dropdown in the form - */ -@Component({ - selector: 'lib-date-dropdown-wrapper', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DateDropdownWrapperComponent), - multi: true, - }, - ], - standalone: true, - template: ` - - `, - imports: [ - IxModule, - FormsModule, - ], -}) -export class DateDropdownWrapperComponent implements ControlValueAccessor { - @ViewChild(IxDateDropdown) private ixDateDropdown: IxDateDropdown; - - value: string; - onChange: (value: string) => NonNullable; - onTouched: () => NonNullable; - - writeValue(value: any): void { - this.value = value; - } - - registerOnChange(fn: any): void { - this.onChange = fn; - } - - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - onDateChange(event: any): void { - const dateString = this.convertDate(event.detail.from); - const date = new Date(dateString); - this.value = date.toISOString().slice(0, 10); - this.onChange(this.value); - } - - convertDate(date: string): string { - const [ day, month, year ] = date.split('-'); - return `${year}-${month}-${day}`; - } -} diff --git a/libs/cases/frontend/view/src/lib/components/open-cases/open-cases.component.ts b/libs/cases/frontend/view/src/lib/components/open-cases/open-cases.component.ts deleted file mode 100644 index bd98077c..00000000 --- a/libs/cases/frontend/view/src/lib/components/open-cases/open-cases.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - ViewEncapsulation, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { RouterLink } from '@angular/router'; -import { XdBrowseFacadesService } from '@frontend/cases/frontend/domain'; -import { IxModule } from '@siemens/ix-angular'; -import { ICaseResponse } from 'cases-shared-models'; - -@Component({ - selector: 'lib-open-cases', - standalone: true, - imports: [ CommonModule, IxModule, RouterLink ], - templateUrl: './open-cases.component.html', - styleUrl: './open-cases.component.scss', - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OpenCasesComponent { - protected readonly _browseFacade = inject(XdBrowseFacadesService); - protected readonly _cases = toSignal(this._browseFacade.getAllCases()); - protected readonly _filteredCases = computed(() => { - const cases = this._cases(); - if (cases === undefined) { - return; - } - cases - .filter((_case: ICaseResponse) => _case.status === 'OPEN') - .sort((a, b) => { - const priorityOrder = [ 'EMERGENCY', 'HIGH', 'MEDIUM', 'LOW' ]; - const priorityAIndex = priorityOrder.indexOf(a.priority.toUpperCase()); - const priorityBIndex = priorityOrder.indexOf(b.priority.toUpperCase()); - - if (priorityAIndex === priorityBIndex) { - return a.id - b.id; - } else if (priorityAIndex === -1) { - return 1; - } else if (priorityBIndex === -1) { - return -1; - } - return priorityAIndex - priorityBIndex; - }); - - return cases; - }); - - getStatusClasses(_case: ICaseResponse) { - return { - emergency: _case.priority === 'EMERGENCY', - 'status-open': _case.status === 'OPEN', - 'status-inprogress': _case.status === 'INPROGRESS', - 'status-overdue': _case.status === 'OVERDUE', - 'status-onhold': _case.status === 'ONHOLD', - 'status-done': _case.status === 'DONE', - 'status-cancelled': _case.status === 'CANCELLED', - 'status-archived': _case.status === 'ARCHIVED', - }; - } -} diff --git a/libs/common/backend/insight-hub/project.json b/libs/common/backend/insight-hub/project.json index 07dcdcce..bc708c16 100644 --- a/libs/common/backend/insight-hub/project.json +++ b/libs/common/backend/insight-hub/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/common/backend/insight-hub/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/common/backend/insight-hub/src/lib/services/base-bearer-interaction.service.spec.ts b/libs/common/backend/insight-hub/src/lib/services/base-bearer-interaction.service.spec.ts index 0da0d677..b93f5861 100644 --- a/libs/common/backend/insight-hub/src/lib/services/base-bearer-interaction.service.spec.ts +++ b/libs/common/backend/insight-hub/src/lib/services/base-bearer-interaction.service.spec.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; import { HttpService } from '@nestjs/axios'; -import { HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; +import { HttpException, Inject, Injectable, Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AxiosResponse } from 'axios'; import { IInsightHub } from 'common-backend-models'; diff --git a/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.spec.ts b/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.spec.ts index 6981984b..52ea6c66 100644 --- a/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.spec.ts +++ b/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.spec.ts @@ -3,6 +3,7 @@ import { HttpService } from '@nestjs/axios'; import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AxiosResponse } from 'axios'; +import { IInsightHub } from 'common-backend-models'; import { firstValueFrom, Observable, of } from 'rxjs'; import { ITimeSeriesRequestParameter } from '../models'; @@ -18,6 +19,7 @@ interface MockSelectParameter { describe('XdIotTimeSeriesService', () => { let service: XdIotTimeSeriesService; let httpService: HttpService; + let insightHubOptions: IInsightHub; beforeEach(async () => { const httpServiceMock = { @@ -55,6 +57,7 @@ describe('XdIotTimeSeriesService', () => { service = module.get(XdIotTimeSeriesService); httpService = module.get(HttpService); + insightHubOptions = module.get(INSIGHT_HUB_OPTIONS); }); it('should be defined', () => { @@ -71,7 +74,7 @@ describe('XdIotTimeSeriesService', () => { from: faker.date.past(), to: faker.date.recent(), limit: faker.number.int(), - select: [ 'flow', 'pressure' ], + select: ['flow', 'pressure'], }; const assetId = faker.string.uuid(); const propertySetName = faker.lorem.word(1); @@ -99,4 +102,56 @@ describe('XdIotTimeSeriesService', () => { expect(response).toEqual(mockResponse); }); }); + + describe('isLocalSession', () => { + it('should return true for isLocalSession when apiKey and apiUrl are undefined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + insightHubOptions.apiKey = null; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + insightHubOptions.apiUrl = null; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return true for isLocalSession when apiKey and apiUrl are empty strings', () => { + insightHubOptions.apiKey = ''; + insightHubOptions.apiUrl = ''; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return true for isLocalSession when apiKey is defined and apiUrl is undefined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + insightHubOptions.apiUrl = null; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return true for isLocalSession when apiKey is undefined and apiUrl is defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + insightHubOptions.apiKey = null; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return true for isLocalSession when apiKey is defined and apiUrl is an empty string', () => { + insightHubOptions.apiUrl = ''; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return true for isLocalSession when apiKey is an empty string and apiUrl is defined', () => { + insightHubOptions.apiKey = ''; + + expect(service.isLocalSession()).toBe(true); + }); + + it('should return false for isLocalSession when apiKey and apiUrl are defined', () => { + expect(service.isLocalSession()).toBe(false); + }); + }); }); diff --git a/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.ts b/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.ts index e0164526..c1005426 100644 --- a/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.ts +++ b/libs/common/backend/insight-hub/src/lib/services/iot-time-series.service.ts @@ -29,7 +29,7 @@ export class XdIotTimeSeriesService extends XdBaseBearerInteractionService { ); } - /** + /** * Allows to get the time series data from the IoT Time Series API. * @see https://documentation.mindsphere.io/MindSphere/apis/iot-iottimeseries/api-iottimeseries-api.html * @@ -44,4 +44,11 @@ export class XdIotTimeSeriesService extends XdBaseBearerInteractionService { ): Observable { return super._getData(`${assetId}/${propertySetName}`, params); } + + /** + * Checks if the session is local or not. + */ + public isLocalSession(): boolean { + return !this.insightHubOptions.apiKey || !this.insightHubOptions.apiUrl; + } } diff --git a/libs/common/backend/prisma/project.json b/libs/common/backend/prisma/project.json index 17f53c77..54efe831 100644 --- a/libs/common/backend/prisma/project.json +++ b/libs/common/backend/prisma/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/common/backend/prisma/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/common/backend/swagger/project.json b/libs/common/backend/swagger/project.json index b7fbb936..f4cac830 100644 --- a/libs/common/backend/swagger/project.json +++ b/libs/common/backend/swagger/project.json @@ -12,6 +12,9 @@ "jestConfig": "libs/common/backend/swagger/jest.config.ts" } }, + "lint": { + "executor": "@nx/eslint:lint" + }, "build": { "executor": "@nx/webpack:webpack", "outputs": ["{options.outputPath}"], diff --git a/libs/common/backend/swagger/src/lib/const/swagger-tag-information.const.ts b/libs/common/backend/swagger/src/lib/const/swagger-tag-information.const.ts index 5d365919..2e09acc4 100644 --- a/libs/common/backend/swagger/src/lib/const/swagger-tag-information.const.ts +++ b/libs/common/backend/swagger/src/lib/const/swagger-tag-information.const.ts @@ -17,4 +17,8 @@ export const SWAGGER_TAG_INFORMATION: Record { let service: AuthenticationService; + const mockSecretKey = 'test_secret_key'; + const mockConfig = { secretKey: mockSecretKey }; beforeEach(() => { - TestBed.configureTestingModule({}); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, + writable: true + }); + + TestBed.configureTestingModule({ + providers: [ + { provide: APP_CONFIG, useValue: mockConfig } + ] + }); service = TestBed.inject(AuthenticationService); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('login', () => { + it('should return true for valid credentials', () => { + jest.spyOn(service as any, 'checkCredentials').mockReturnValue(true); + expect(service.login('test@example.com', 'password')).toBe(true); + }); + + it('should return false for invalid credentials', () => { + jest.spyOn(service as any, 'checkCredentials').mockReturnValue(false); + expect(service.login('test@example.com', 'wrong_password')).toBe(false); + }); + }); + + describe('logout', () => { + it('should remove token from localStorage', () => { + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + service.logout(); + expect(removeItemSpy).toHaveBeenCalledWith('authToken'); + }); + }); + + describe('isLoggedIn', () => { + it('should return false if no token in localStorage', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(null); + expect(service.isLoggedIn()).toBe(false); + }); + + it('should return false if token cannot be decrypted', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('invalid_token'); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(null); + expect(service.isLoggedIn()).toBe(false); + }); + + it('should return false if credentials are invalid', () => { + const token = 'valid_encrypted_token'; + const userData = { userMail: 'test@example.com', password: 'password', time: new Date().getTime() }; + jest.spyOn(localStorage, 'getItem').mockReturnValue(token); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(userData); + jest.spyOn(service as any, 'checkCredentials').mockReturnValue(false); + expect(service.isLoggedIn()).toBe(false); + }); + + it('should return false if token is expired', () => { + const token = 'valid_encrypted_token'; + const expiredTime = new Date().getTime() - 1000 * 60 * 60 - 1000; + const userData = { userMail: 'test@example.com', password: 'password', time: expiredTime }; + jest.spyOn(localStorage, 'getItem').mockReturnValue(token); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(userData); + expect(service.isLoggedIn()).toBe(false); + }); + + it('should return true for valid token and credentials', () => { + const token = 'valid_encrypted_token'; + const userData = { userMail: 'test@example.com', password: 'password', time: new Date().getTime() }; + jest.spyOn(localStorage, 'getItem').mockReturnValue(token); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(userData); + jest.spyOn(service as any, 'checkCredentials').mockReturnValue(true); + expect(service.isLoggedIn()).toBe(true); + }); + }); + + describe('getUserMail', () => { + it('should return false if no token in localStorage', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(null); + expect(service.getUserMail()).toBe(false); + }); + + it('should return false if token cannot be decrypted', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('invalid_token'); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(null); + expect(service.getUserMail()).toBe(false); + }); + + it('should return user email if token is valid', () => { + const token = 'valid_encrypted_token'; + const userData = { userMail: 'test@example.com', password: 'password', time: new Date().getTime() }; + jest.spyOn(localStorage, 'getItem').mockReturnValue(token); + jest.spyOn(service as any, 'decryptToken').mockReturnValue(userData); + expect(service.getUserMail()).toBe('test@example.com'); + }); + }); }); diff --git a/libs/common/frontend/models/src/lib/services/authentication.service.ts b/libs/common/frontend/models/src/lib/services/authentication.service.ts index a2e9de49..1bcfe019 100644 --- a/libs/common/frontend/models/src/lib/services/authentication.service.ts +++ b/libs/common/frontend/models/src/lib/services/authentication.service.ts @@ -32,8 +32,8 @@ export class AuthenticationService { login(email: string, password: string) { if (this.checkCredentials(email, password)) { - const token = this.generateToken(email, password); - localStorage.setItem(this.tokenKey, token); + const token = this.generateToken(email, password); + localStorage.setItem(this.tokenKey, token); return true; } diff --git a/libs/facilities/backend/facilities/project.json b/libs/facilities/backend/facilities/project.json index 75a7378a..483d5b6b 100644 --- a/libs/facilities/backend/facilities/project.json +++ b/libs/facilities/backend/facilities/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/facilities/backend/facilities/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/facilities/backend/metrics/.eslintrc.json b/libs/facilities/backend/metrics/.eslintrc.json new file mode 100644 index 00000000..274cb3ea --- /dev/null +++ b/libs/facilities/backend/metrics/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/facilities/backend/metrics/README.md b/libs/facilities/backend/metrics/README.md new file mode 100644 index 00000000..318a605e --- /dev/null +++ b/libs/facilities/backend/metrics/README.md @@ -0,0 +1,3 @@ +# facilities-backend-metrics + +This library provides a set of classes and functions to collect and report metrics from the backend services. diff --git a/libs/facilities/backend/metrics/jest.config.ts b/libs/facilities/backend/metrics/jest.config.ts new file mode 100644 index 00000000..65672883 --- /dev/null +++ b/libs/facilities/backend/metrics/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'facilities-backend-metrics', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/libs/facilities/backend/metrics', +}; diff --git a/libs/facilities/backend/metrics/project.json b/libs/facilities/backend/metrics/project.json new file mode 100644 index 00000000..d167801e --- /dev/null +++ b/libs/facilities/backend/metrics/project.json @@ -0,0 +1,16 @@ +{ + "name": "facilities-backend-metrics", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/facilities/backend/metrics/src", + "projectType": "library", + "tags": ["domain:facilities", "kind:backend", "type:feature"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/facilities/backend/metrics/jest.config.ts" + } + } + } +} diff --git a/libs/facilities/backend/metrics/src/index.ts b/libs/facilities/backend/metrics/src/index.ts new file mode 100644 index 00000000..6ac890ca --- /dev/null +++ b/libs/facilities/backend/metrics/src/index.ts @@ -0,0 +1 @@ +export * from './lib/facilities-backend-metrics.module'; diff --git a/libs/facilities/backend/metrics/src/lib/controller/metrics.controller.ts b/libs/facilities/backend/metrics/src/lib/controller/metrics.controller.ts new file mode 100644 index 00000000..13d899bd --- /dev/null +++ b/libs/facilities/backend/metrics/src/lib/controller/metrics.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ESwaggerTag } from 'common-backend-swagger'; +import { GetTimeSeriesParamsDto } from 'facilities-backend-timeseries'; + +import { XdMetricsService } from '../services/metrics.service'; + +@ApiTags(ESwaggerTag.METRICS) +@Controller('metrics') +export class XdMetricsController { + constructor(private readonly metricsService: XdMetricsService) {} + + /** + * Get the metrics for the asset + * + * @param params + */ + @Get(':assetId/:propertySetName') + @ApiOkResponse({ description: 'Returns metrics data for an asset' }) + public getMetricsForAsset(@Param() params: GetTimeSeriesParamsDto) { + return this.metricsService.getMetricsForAsset(params.assetId, params.propertySetName); + } +} diff --git a/libs/facilities/backend/metrics/src/lib/facilities-backend-metrics.module.ts b/libs/facilities/backend/metrics/src/lib/facilities-backend-metrics.module.ts new file mode 100644 index 00000000..bcf348d3 --- /dev/null +++ b/libs/facilities/backend/metrics/src/lib/facilities-backend-metrics.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { XdPrismaModule } from 'common-backend-prisma'; +import { XdTimeseriesModule } from 'facilities-backend-timeseries'; + +import { XdMetricsController } from './controller/metrics.controller'; +import { XdMetricsService } from './services/metrics.service'; + +@Module({ + imports: [XdTimeseriesModule, XdPrismaModule], + controllers: [XdMetricsController], + providers: [XdMetricsService], + exports: [XdMetricsService], +}) +export class XdMetricsModule {} diff --git a/libs/facilities/backend/metrics/src/lib/services/metrics.service.ts b/libs/facilities/backend/metrics/src/lib/services/metrics.service.ts new file mode 100644 index 00000000..09fec0f5 --- /dev/null +++ b/libs/facilities/backend/metrics/src/lib/services/metrics.service.ts @@ -0,0 +1,93 @@ +import { checkPumpStatus } from '@frontend/facilities/backend/utils'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { PrismaService } from 'common-backend-prisma'; +import { XdTimeseriesService } from 'facilities-backend-timeseries'; +import { ITimeSeriesPumpReport } from 'facilities-shared-models'; +import { from, map, switchMap } from 'rxjs'; +import dayjs = require('dayjs'); +import { pick } from 'lodash'; + +@Injectable() +export class XdMetricsService { + constructor( + @Inject(forwardRef(() => PrismaService)) + private readonly prismaService: PrismaService, + + private readonly timeSeriesService: XdTimeseriesService, + ) {} + + /** + * Get the metrics for the asset + * + * @param assetId + * @param propertySetName + */ + public getMetricsForAsset(assetId: string, propertySetName: string) { + return this.timeSeriesService + .getTimeSeries({ + assetId, + propertySetName, + from: dayjs().subtract(60, 'minute').toDate(), + }) + .pipe( + switchMap((data) => { + return this.upsertMetrics(assetId, propertySetName, data); + }), + ); + } + + /** + * Calculates and upserts the metrics for the asset + * + * @param assetId + * @param propertySetName + * @param data + * @private + */ + private upsertMetrics(assetId: string, propertySetName: string, data: unknown): any { + switch (propertySetName) { + case 'PumpData': { + const { status, indicatorMsg, metrics } = checkPumpStatus( + data as unknown as ITimeSeriesPumpReport[], + ); + return from( + this.prismaService.metrics.deleteMany({ + where: { + assetId: assetId, + }, + }), + ).pipe( + switchMap(() => { + return this.prismaService.asset + .update({ + where: { + assetId, + }, + data: { + status, + indicatorMsg, + metrics: { + create: metrics, + }, + }, + }) + .metrics(); + }), + map((metrics) => + metrics.map((m) => ({ + ...pick(m, [ + 'name', + 'min', + 'max', + 'variance', + 'standardDeviation', + 'coefficientOfVariation', + 'mean', + ]), + })), + ), + ); + } + } + } +} diff --git a/libs/facilities/backend/metrics/tsconfig.json b/libs/facilities/backend/metrics/tsconfig.json new file mode 100644 index 00000000..914bbe84 --- /dev/null +++ b/libs/facilities/backend/metrics/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/facilities/backend/metrics/tsconfig.lib.json b/libs/facilities/backend/metrics/tsconfig.lib.json new file mode 100644 index 00000000..4a5af54f --- /dev/null +++ b/libs/facilities/backend/metrics/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/facilities/backend/metrics/tsconfig.spec.json b/libs/facilities/backend/metrics/tsconfig.spec.json new file mode 100644 index 00000000..33157e0a --- /dev/null +++ b/libs/facilities/backend/metrics/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/facilities/backend/models/project.json b/libs/facilities/backend/models/project.json index d270ac2e..6c5d1a87 100644 --- a/libs/facilities/backend/models/project.json +++ b/libs/facilities/backend/models/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/facilities/backend/models/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/facilities/backend/timeseries/project.json b/libs/facilities/backend/timeseries/project.json index aa081fd5..70cafa69 100644 --- a/libs/facilities/backend/timeseries/project.json +++ b/libs/facilities/backend/timeseries/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/facilities/backend/timeseries/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/facilities/backend/timeseries/src/index.ts b/libs/facilities/backend/timeseries/src/index.ts index a8924ea1..f41a696f 100644 --- a/libs/facilities/backend/timeseries/src/index.ts +++ b/libs/facilities/backend/timeseries/src/index.ts @@ -1 +1 @@ -export * from './lib/timeseries.module'; +export * from './lib'; diff --git a/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.spec.ts b/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.spec.ts index 7a3cb400..8095b6fe 100644 --- a/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.spec.ts +++ b/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.spec.ts @@ -30,6 +30,12 @@ describe('TimeseriesController ', () => { { time: faker.date.recent(), test: faker.string.sample() }, ] as ITimeSeriesDataItemResponse[]) as Observable, ), + getTimeSeries: jest.fn().mockReturnValue( + of([ + { time: faker.date.recent(), test: faker.string.sample() }, + { time: faker.date.recent(), test: faker.string.sample() }, + ] as ITimeSeriesDataItemResponse[]) as Observable, + ), getTimeSeriesFromDB: jest.fn().mockReturnValue( of([ { time: faker.date.recent(), test: faker.string.sample() }, @@ -40,7 +46,7 @@ describe('TimeseriesController ', () => { }; const module = await Test.createTestingModule({ - controllers: [ XdTimeseriesController ], + controllers: [XdTimeseriesController], providers: [ { provide: XdTimeseriesService, @@ -85,7 +91,7 @@ describe('TimeseriesController ', () => { const from = faker.date.recent(); const to = faker.date.recent(); const limit = faker.number.int(10); - const select = [ 'test' ]; + const select = ['test']; const sort = ESortOrder.ASCENDING; const latestValue = true; @@ -94,66 +100,10 @@ describe('TimeseriesController ', () => { { time: faker.date.recent(), test: faker.string.sample() }, ] as ITimeSeriesDataItemResponse[]; - const spy = jest - .spyOn(service, 'getTimeSeriesFromDB') - .mockReturnValue(of(returnValue) as Observable); - - const result = await firstValueFrom( - controller.getTimeSeries( - { - assetId, - propertySetName, - }, - { - from, - to, - limit, - select, - sort, - latestValue, - local: true, - }, - ), + jest.spyOn(service, 'getTimeSeries').mockReturnValue( + of(returnValue) as Observable, ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - assetId, - propertySetName, - from, - to, - limit, - select, - sort, - latestValue, - }); - expect(result).toEqual(returnValue); - }); - - it(' should call getTimeSeriesFromApi when local is false', async () => { - const assetId = faker.string.uuid(); - const propertySetName = faker.string.uuid(); - const from = faker.date.recent(); - const to = faker.date.recent(); - const limit = faker.number.int(10); - const select = [ 'test' ]; - const sort = ESortOrder.ASCENDING; - const latestValue = true; - const local = false; - - const returnValue = [ - { time: faker.date.recent(), test: faker.string.sample() }, - { time: faker.date.recent(), test: faker.string.sample() }, - ] as ITimeSeriesDataItemResponse[]; - - const spyApi = jest - .spyOn(service, 'getTimeSeriesFromApi') - .mockReturnValue(of(returnValue) as Observable); - - const spyDb = jest - .spyOn(service, 'getTimeSeriesFromDB') - .mockReturnValue(of(returnValue) as Observable); - const result = await firstValueFrom( controller.getTimeSeries( { @@ -167,24 +117,11 @@ describe('TimeseriesController ', () => { select, sort, latestValue, - local, + local: true, }, ), ); - expect(spyApi).toHaveBeenCalledTimes(1); - expect(spyApi).toHaveBeenCalledWith({ - assetId, - propertySetName, - from, - to, - limit, - select, - sort, - latestValue, - }); expect(result).toEqual(returnValue); - - expect(spyDb).toHaveBeenCalledTimes(0); }); }); diff --git a/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.ts b/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.ts index 422013ea..12abcc69 100644 --- a/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.ts +++ b/libs/facilities/backend/timeseries/src/lib/controller/timeseries.controller.ts @@ -47,14 +47,9 @@ export class XdTimeseriesController { @Param() params: GetTimeSeriesParamsDto, @Query() query: GetTimeSeriesQueryDto, ): Observable { - const { local = false, ...rest } = query; - const args = { - ...params, - ...rest, - }; - - return local - ? this.timeseriesService.getTimeSeriesFromDB(args) - : this.timeseriesService.getTimeSeriesFromApi(args); + return this.timeseriesService.getTimeSeries({ + ...params, + ...query, + }); } } diff --git a/libs/facilities/backend/timeseries/src/lib/index.ts b/libs/facilities/backend/timeseries/src/lib/index.ts new file mode 100644 index 00000000..3d6d8c89 --- /dev/null +++ b/libs/facilities/backend/timeseries/src/lib/index.ts @@ -0,0 +1,5 @@ +export * from './dto'; +export * from './services'; + +export * from './timeseries.module'; + diff --git a/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.spec.ts b/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.spec.ts index 9bf23c89..9734a9b6 100644 --- a/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.spec.ts +++ b/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.spec.ts @@ -7,7 +7,6 @@ import { XdIotTimeSeriesService } from 'common-backend-insight-hub'; import { XdTokenManagerService } from 'common-backend-insight-hub'; import { PrismaService } from 'common-backend-prisma'; import { ESortOrder, IGetTimeSeriesParams, IGetTimeseriesQuery } from 'facilities-shared-models'; -import { omit } from 'lodash'; import { lastValueFrom, of } from 'rxjs'; import { XdTimeseriesService } from './timeseries.service'; @@ -32,6 +31,12 @@ describe('TimeseriesService', () => { data: JSON.stringify({ test: 'test', test2: 'test2' }), }, ]), + findFirst: jest.fn().mockImplementation(() => [ + { + time: new Date(), + data: JSON.stringify({ test: 'test', test2: 'test2' }), + }, + ]), upsert: jest.fn(), }, @@ -63,6 +68,7 @@ describe('TimeseriesService', () => { provide: XdIotTimeSeriesService, useValue: { getTimeSeriesData: jest.fn().mockReturnValue(of([])), + isLocalSession: jest.fn().mockReturnValue(true), }, }, { @@ -119,7 +125,7 @@ describe('TimeseriesService', () => { const findManySpy = jest .spyOn(prisma.timeSeriesDataItem, 'findMany') - .mockResolvedValue([ findManyResult ]); + .mockResolvedValue([findManyResult]); const params: IGetTimeSeriesParams = { assetId: findManyResult.timeSeriesItemAssetId, @@ -129,19 +135,13 @@ describe('TimeseriesService', () => { const result = await lastValueFrom( service.getTimeSeriesFromDB({ ...params, - select: [ 'flow', 'presure' ], + select: ['flow', 'presure'], }), ); expect(findManySpy).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { - time: findManyResult.time, - flow: flow, - presure: presure, - }, - ]); + expect(result[0]['flow']).toEqual(flow); }); it('should call selectKeysFromJSON only with the selected Props', async () => { @@ -158,7 +158,7 @@ describe('TimeseriesService', () => { const findManySpy = jest .spyOn(prisma.timeSeriesDataItem, 'findMany') - .mockResolvedValue([ findManyResult ]); + .mockResolvedValue([findManyResult]); const params: IGetTimeSeriesParams = { assetId: findManyResult.timeSeriesItemAssetId, @@ -166,7 +166,7 @@ describe('TimeseriesService', () => { }; const query: IGetTimeseriesQuery = { - select: [ 'flow' ], + select: ['flow'], }; const result = await lastValueFrom( @@ -195,19 +195,13 @@ describe('TimeseriesService', () => { .spyOn(prisma.timeSeriesDataItem, 'findMany') .mockResolvedValue([]); - jest.spyOn(prisma.timeSeriesItem, 'findUnique').mockResolvedValue({ - assetId: faker.string.uuid(), - propertySetName: faker.string.sample(), - variables: {}, - }); - const params: IGetTimeSeriesParams = { assetId: faker.string.uuid(), propertySetName: faker.string.sample(), }; const query: IGetTimeseriesQuery = { - select: [ 'flow' ], + select: ['flow'], }; await lastValueFrom( @@ -217,14 +211,12 @@ describe('TimeseriesService', () => { }), ); - expect(getTimeSeriesDataSpy).toHaveBeenCalledTimes(1); - expect(findManySpy).toHaveBeenCalledTimes(0); expect(getTimeSeriesDataSpy).toHaveBeenCalledWith( params.assetId, params.propertySetName, - omit(query, 'select'), + query, ); await lastValueFrom( @@ -238,6 +230,48 @@ describe('TimeseriesService', () => { expect(findManySpy).toHaveBeenCalledTimes(1); }); + it('should use local db when api iot service decides its a local session', async () => { + const getTimeSeriesDataSpy = jest + .spyOn(iothub, 'getTimeSeriesData') + .mockReturnValue(of([])); + + const findManySpy = jest + .spyOn(prisma.timeSeriesDataItem, 'findMany') + .mockResolvedValue([]); + + const isLocalSessionSpy = jest.spyOn(iothub, 'isLocalSession'); + + const params: IGetTimeSeriesParams = { + assetId: faker.string.uuid(), + propertySetName: faker.string.sample(), + }; + + const query: IGetTimeseriesQuery = { + select: ['flow'], + }; + + isLocalSessionSpy.mockReturnValue(false); + + await lastValueFrom( + service.getTimeSeries({ + ...params, + ...query, + }), + ); + + isLocalSessionSpy.mockReturnValue(true); + + await lastValueFrom( + service.getTimeSeries({ + ...params, + ...query, + }), + ); + + expect(getTimeSeriesDataSpy).toHaveBeenCalledTimes(1); + expect(findManySpy).toHaveBeenCalledTimes(1); + }); + it('should use the correct args to query the time series data', async () => { const findManySpy = jest .spyOn(prisma.timeSeriesDataItem, 'findMany') diff --git a/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.ts b/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.ts index b656376c..d13c3210 100644 --- a/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.ts +++ b/libs/facilities/backend/timeseries/src/lib/services/timeseries.service.ts @@ -1,15 +1,15 @@ -import { checkPumpStatus } from '@frontend/facilities/backend/utils'; import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ETimeSeriesOrdering, XdIotTimeSeriesService } from 'common-backend-insight-hub'; import { PrismaService } from 'common-backend-prisma'; import { - IGetTimeSeriesParams, - IGetTimeseriesQuery, - ITimeSeriesDataItemResponse, - ITimeSeriesItemResponse, ITimeSeriesPumpReport, + IGetTimeSeriesParams, + IGetTimeseriesQuery, + ITimeSeriesDataItemResponse, + ITimeSeriesItemResponse, } from 'facilities-shared-models'; -import { pick } from 'lodash'; -import { catchError, from, map, Observable, switchMap } from 'rxjs'; +import { omit } from 'lodash'; +import { from, map, Observable, switchMap } from 'rxjs'; +import dayjs = require('dayjs'); @Injectable() export class XdTimeseriesService { @@ -20,153 +20,128 @@ export class XdTimeseriesService { private readonly iotTimeSeriesService: XdIotTimeSeriesService, ) {} + /** + * Acts as a gateway to get the time series data from either the API or the DB. + * + * @param args - the query and parameters based arguments to get the time series data + */ + public getTimeSeries(args: IGetTimeSeriesParams & IGetTimeseriesQuery) { + const result$ = this.iotTimeSeriesService.isLocalSession() + ? this.getTimeSeriesFromDB(args) + : this.getTimeSeriesFromApi(args); + + return result$; + } + /** * Get timeseries data based on the assetId and propertySetName from the API + * + * @param args - the query and parameters based arguments to get the time series data */ public getTimeSeriesFromApi( args: IGetTimeSeriesParams & IGetTimeseriesQuery, ): Observable { - const { assetId, propertySetName, sort, select, ...params } = args; + const { assetId, propertySetName, sort, ...params } = args; + return this.iotTimeSeriesService + .getTimeSeriesData< + any, + { + _time: string; + [key: string]: any; + }[] + >(assetId, propertySetName, { + ...params, + // Todo: Fix this in a future PR + sort: sort as unknown as ETimeSeriesOrdering, + }) + .pipe( + map((items) => { + return items.map((item) => ({ + time: new Date(item._time), + ...omit(item, '_time'), + })); + }), + ); + } + private findFirstTime(assetId: string, propertySetName: string) { return from( - this.prismaService.timeSeriesItem.findUnique({ - where: { assetId_propertySetName: { assetId, propertySetName } }, - }), - ).pipe( - map((item) => { - if (!item) { - throw new HttpException( - `No timeseries data found for assetId: ${assetId} and propertySetName: ${propertySetName}`, - HttpStatus.NOT_FOUND, - ); - } - return item; - }), - switchMap(() => { - return this.iotTimeSeriesService - .getTimeSeriesData< - any, - { - _time: string; - - [key: string]: any; - }[] - >(assetId, propertySetName, { - ...params, - // Todo: Fix this in a future PR - sort: sort as unknown as ETimeSeriesOrdering, - }) - .pipe( - map((items) => { - const data = items.map((item) => { - const { _time, ...rest } = item; - return { - ...rest, - time: new Date(_time), - }; - }); - const { status, indicatorMsg, metrics } = checkPumpStatus( - data as unknown as ITimeSeriesPumpReport[], - ); - - const timeSeriesData = data.map(({ time, ...rest }) => { - return this.prismaService.timeSeriesDataItem.upsert({ - where: { - timeSeriesItemAssetId_timeSeriesItemPropertySetName_time: { - timeSeriesItemAssetId: assetId, - timeSeriesItemPropertySetName: propertySetName, - time: time, - }, - }, - update: {}, - create: { - time: time, - timeSeriesItemAssetId: assetId, - timeSeriesItemPropertySetName: propertySetName, - data: rest, - }, - }); - }); - - const updatedPumpData = this.prismaService.asset.upsert({ - where: { - assetId, - }, - update: { - status, - indicatorMsg, - metrics: { - deleteMany: {}, - create: metrics - } - }, - create: { - assetId, - status, - indicatorMsg, - location: { - create: { - latitude: 0, - longitude: 0, - }, - }, - name: 'Pump', - typeId: 'pump', - metrics: { - create: metrics - } - }, - }); - - this.prismaService.$transaction([ ...timeSeriesData, updatedPumpData ]); - - if (select) { - return data.map((item) => ({ - ...pick(item, select), - time: item.time, - })); - } - - return data; - }), - ); + this.prismaService.timeSeriesDataItem.findFirst({ + where: { + timeSeriesItemAssetId: assetId, + timeSeriesItemPropertySetName: propertySetName, + }, + orderBy: { + time: 'desc', + }, }), ); } + private normalizeTimes(time: Date, from?: Date, to?: Date) { + let normalizedFromTime: Date | undefined; + let normalizedToTime: Date | undefined; + const timeDifference = dayjs().diff(time, 'millisecond', true); + + if (from) { + normalizedFromTime = dayjs(from).subtract(timeDifference, 'millisecond').toDate(); + } + if (to) { + normalizedToTime = dayjs(to).subtract(timeDifference, 'millisecond').toDate(); + } + + return { normalizedFromTime, normalizedToTime, timeDifference }; + } + /** * Get timeseries data based on the assetId and propertySetName from the DB + * + * @param args - the query and parameters based arguments to get the time series data */ public getTimeSeriesFromDB( args: IGetTimeSeriesParams & IGetTimeseriesQuery, ): Observable { const { assetId, propertySetName } = args; - return from( - this.prismaService.timeSeriesDataItem.findMany({ - where: { - timeSeriesItemAssetId: assetId, - timeSeriesItemPropertySetName: propertySetName, - time: { - gte: args.from, - lte: args.to, - }, - }, - take: args.limit, - orderBy: { - time: args.sort, - }, + return this.findFirstTime(assetId, propertySetName).pipe( + switchMap((item) => { + if (!item) { + throw new HttpException('timeSeriesItem not found', HttpStatus.NOT_FOUND); + } + + const { normalizedFromTime, normalizedToTime, timeDifference } = + this.normalizeTimes(item.time, args.from, args.to); + + return from( + this.prismaService.timeSeriesDataItem.findMany({ + where: { + timeSeriesItemAssetId: assetId, + timeSeriesItemPropertySetName: propertySetName, + time: { + gte: normalizedFromTime, + lte: normalizedToTime, + }, + }, + take: args.limit, + orderBy: { + time: args.sort, + }, + }), + ).pipe(map((result) => ({ result, timeDifference }))); }), - ).pipe( - map((items) => { - return items.map((item) => ({ - time: item.time, + map(({ result, timeDifference }) => { + if (!Array.isArray(result)) { + throw new HttpException( + 'Unexpected result format', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return result.map((item) => ({ + time: dayjs(item.time).add(timeDifference, 'millisecond').toDate(), ...this.prismaService.selectKeysFromJSON(item.data, args.select), })); }), - catchError((err: Error) => { - throw err; - }), ); } diff --git a/libs/facilities/backend/utils/project.json b/libs/facilities/backend/utils/project.json index fa18701c..c5839ac0 100644 --- a/libs/facilities/backend/utils/project.json +++ b/libs/facilities/backend/utils/project.json @@ -11,6 +11,9 @@ "options": { "jestConfig": "libs/facilities/backend/utils/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.spec.ts b/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.spec.ts index fa785f26..9cb9dee5 100644 --- a/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.spec.ts +++ b/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { FacilitiesRequestService } from '../../infrastructure/facilities-request.service'; +import { MetricsRequestService } from '../../infrastructure/metrics-request.service'; import { TimeSeriesRequestService } from '../../infrastructure/timeseries-request.service'; import { XdDetailsFacade } from './details.facade'; @@ -29,6 +30,12 @@ describe('XdDetailsFacadeService', () => { getTimeSeriesDataItems: jest.fn().mockReturnValue(of([])), }, }, + { + provide: MetricsRequestService, + useValue: { + getMetrics: jest.fn().mockReturnValue(of([])), + }, + }, ], }); diff --git a/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.ts b/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.ts index c73ae0d4..f158c107 100644 --- a/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.ts +++ b/libs/facilities/frontend/domain/src/lib/application/facades/details.facade.ts @@ -1,8 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { faker } from '@faker-js/faker'; +import * as dayjs from 'dayjs'; import { map } from 'rxjs'; import { FacilitiesRequestService } from '../../infrastructure/facilities-request.service'; +import { MetricsRequestService } from '../../infrastructure/metrics-request.service'; import { TimeSeriesRequestService } from '../../infrastructure/timeseries-request.service'; /** @@ -12,6 +14,7 @@ import { TimeSeriesRequestService } from '../../infrastructure/timeseries-reques export class XdDetailsFacade { private readonly _facilitiesService = inject(FacilitiesRequestService); private readonly _timeseriesService = inject(TimeSeriesRequestService); + private readonly _metricsService = inject(MetricsRequestService); /** * Get facility @@ -29,12 +32,12 @@ export class XdDetailsFacade { 'water-plant', 'truck', ]), - cases: timeSeriesItem.cases, + cases: timeSeriesItem.cases, heading: timeSeriesItem.name, subheading: timeSeriesItem.description, status: timeSeriesItem.status, - metrics: timeSeriesItem.metrics, - indicatorMsg: timeSeriesItem.indicatorMsg, + metrics: timeSeriesItem.metrics, + indicatorMsg: timeSeriesItem.indicatorMsg, pumps: faker.number.int({ min: 0, max: 99 }), location: timeSeriesItem.location, }; @@ -42,6 +45,16 @@ export class XdDetailsFacade { ); } + /** + * Returns the metrics for the asset + * + * @param assetId + * @param propertySetName + */ + public getMetrics(assetId: string, propertySetName: string) { + return this._metricsService.getMetrics({ assetId, propertySetName }); + } + /** * Get a list of all the available timeSeries properties * @param assetId The asset id. @@ -51,15 +64,32 @@ export class XdDetailsFacade { } /** - * Get the specific data of a time series property of a facility + * Get the specific data of a time series property of a facility of the last 28 minutes + * * @param assetId The asset id. - * @param propertySetName The property set name for which we will get the data. - * @param queryParams The query parameters. */ - public getTimeSeriesDataItems(assetId: string, propertySetName: string, queryParams: any) { + public getPumpData(assetId: string) { + return this._timeseriesService.getTimeSeriesDataItems( + { assetId, propertySetName: 'PumpData' }, + { + from: dayjs().subtract(28, 'minute').toDate(), + to: dayjs().toDate(), + }, + ); + } + + /** + * Get the environment data of a facility of the last 24 hours + * + * @param assetId + */ + public getEnvironmentData(assetId: string) { return this._timeseriesService.getTimeSeriesDataItems( - { assetId, propertySetName }, - queryParams, + { assetId, propertySetName: 'Environment' }, + { + from: dayjs().subtract(24, 'hours').toDate(), + to: dayjs().toDate(), + }, ); } } diff --git a/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.spec.ts b/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.spec.ts new file mode 100644 index 00000000..0c78d07a --- /dev/null +++ b/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.spec.ts @@ -0,0 +1,52 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { faker } from '@faker-js/faker'; +import { IGetTimeSeriesParams, IPumpMetrics } from 'facilities-shared-models'; +import { firstValueFrom, of } from 'rxjs'; + +import { MetricsRequestService } from './metrics-request.service'; +import { TimeSeriesRequestService } from './timeseries-request.service'; + +describe('MetricsService', () => { + let service: MetricsRequestService; + let httpClient: HttpClient; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule ], + providers: [ + TimeSeriesRequestService, + { + provide: HttpClient, + useValue: { + get: jest.fn(), + }, + }, + ], + }); + + service = TestBed.inject(MetricsRequestService); + httpClient = TestBed.inject(HttpClient); + }); + + describe('getMetrics', () => { + it('should fetch time series data', async () => { + const mockResponse: IPumpMetrics[] = []; + + const params: IGetTimeSeriesParams = { + assetId: faker.string.uuid(), + propertySetName: 'PumpData', + }; + + const spy = jest.spyOn(httpClient, 'get').mockReturnValue(of(mockResponse)); + + const result = await firstValueFrom(service.getMetrics(params)); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + `/api/metrics/${params.assetId}/${params.propertySetName}`, + ); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.ts b/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.ts new file mode 100644 index 00000000..8b386817 --- /dev/null +++ b/libs/facilities/frontend/domain/src/lib/infrastructure/metrics-request.service.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { + IGetTimeSeriesParams, IPumpMetrics, +} from 'facilities-shared-models'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class MetricsRequestService { + private readonly _httpClient = inject(HttpClient); + private readonly _baseRoute = '/api/metrics'; + + /** + * Get the metrics for the asset + * + * @param params + */ + public getMetrics(params: IGetTimeSeriesParams): Observable { + return this._httpClient.get(`${this._baseRoute}/${params.assetId}/${params.propertySetName}`); + } + +} diff --git a/libs/facilities/frontend/view/src/lib/components/detail/detail.page.html b/libs/facilities/frontend/view/src/lib/components/detail/detail.page.html index 5266ee8a..d7985a22 100644 --- a/libs/facilities/frontend/view/src/lib/components/detail/detail.page.html +++ b/libs/facilities/frontend/view/src/lib/components/detail/detail.page.html @@ -7,136 +7,138 @@ > @if (facility(); as facility) { - -
- @if (this.pumpChart(); as pumpChart) { -
- } - @if (this.envChart(); as envChart) { -
- } - @if (this.metricsChart(); as metricsChart) { -
- } + +
+ @if(this.pumpChart(); as pumpChart){ +
+ } @if(this.envChart(); as envChart){ +
+ } @if(this.metricsChart(); as metricsChart){ +
+ } - - - - - Basic information - - {{ facility.heading }} - {{ facility.subheading }} - - @if (facility.location?.streetAddress !== undefined && facility.location?.locality !== undefined && facility.location?.country !== undefined) { -

Location: {{ facility.location?.streetAddress }} - , {{ facility.location?.postalCode }} {{ facility.location?.locality }} - , {{ facility.location?.country }}

- } @else if (facility.location?.streetAddress !== undefined && facility.location?.locality !== undefined) { -

Location: {{ facility.location?.streetAddress }} - , {{ facility.location?.postalCode }} {{ facility.location?.locality }}

- } @else if (facility.location?.country !== undefined) { - Location: {{ facility.location?.postalCode }} {{ facility.location?.country }} - } @else if (facility.location?.locality !== undefined) { -

Location: {{ facility.location?.postalCode }} {{ facility.location?.locality }}

- } @else { -

Location: Not specified

- } -
-
- status:  - {{ facility.status }} - - {{ facility.indicatorMsg }} - -
-
-
+ + + + + Basic information + + {{ facility.heading }} + {{ facility.subheading }} + + @if(facility.location?.streetAddress !== undefined && + facility.location?.locality !== undefined && facility.location?.country !== + undefined){ +

+ Location: {{ facility.location?.streetAddress }}, {{ + facility.location?.postalCode }} {{ facility.location?.locality }}, {{ + facility.location?.country }} +

+ } @else if(facility.location?.streetAddress !== undefined && + facility.location?.locality !== undefined){ +

+ Location: {{ facility.location?.streetAddress }}, {{ + facility.location?.postalCode }} {{ facility.location?.locality }} +

+ } @else if (facility.location?.country !== undefined) { Location: {{ + facility.location?.postalCode }} {{ facility.location?.country }} } @else if + (facility.location?.locality !== undefined) { +

+ Location: {{ facility.location?.postalCode}} {{ facility.location?.locality + }} +

+ } @else { +

Location: Not specified

+ } +
+
+ status:  + {{ facility.status }} + + {{ facility.indicatorMsg }} +
+
+
- - - - - {{ facility.cases.length > 0 ? facility.cases.length : '' }} - - Open Cases - - {{ notificationText() }} - -
- @if (facility.cases.length > 0) { -
-
- - @for (caseItem of facility.cases.slice(0, facility.cases.length === 4 ? 4 : 3); - track caseItem.id; let i = $index) { - - Case with id {{ caseItem.id }} - - } -
- @if (facility.cases.length > 4) { - - Go to all Cases - - } -
- } + + + + + {{ facility.cases.length > 0 ? facility.cases.length : '' }} + + Open Cases + {{ notificationText() }} +
+ @if (facility.cases.length > 0) { +
+
+ + @for (caseItem of facility.cases.slice(0, facility.cases.length === 4 ? + 4 : 3); track caseItem.id; let i=$index){ + + Case with id {{ caseItem.id }} + + } +
+ @if (facility.cases.length > 4 ) { + Go to all Cases + } +
+ } -
- - New Case - -
-
+
+ + New Case + +
+
+
+
- - + + + + + + + {{ locked() ? 'Unlock the doors' : 'Lock the doors' }} + + + {{ locked() ? 'Currently the doors are locked' : 'Currently the doors are + unlocked' }} + - - - - - - - {{ locked() ? 'Unlock the doors' : 'Lock the doors' }} - - - {{ - locked() ? 'Currently the doors are locked' : - 'Currently the doors are unlocked' - }} - +
+ + {{ locked() ? 'Unlock the doors' : 'Lock the doors' }} + +
+
+
-
- - {{ locked() ? 'Unlock the doors' : 'Lock the doors' }} - -
-
-
- - - - - - 80% - - Eco Score (SiGreen) - - According to SiGreen this facility has an eco score of 80% - - - -
-
+ + + + + 80% + + Eco Score (SiGreen) + + According to SiGreen this facility has an eco score of 80% + + + +
+
} @else { { + const facility = this.facility(); + if (!facility) return undefined; - protected readonly notificationText = computed(() => { - const facility = this.facility(); - if (!facility) return undefined; - - switch (facility.cases.length) { - case 0: - return 'There are no cases regarding this facility'; - case 1: - return 'There is one case regarding this facility'; - default: - return `There are ${facility.cases.length} cases regarding this facility`; - } - }) + switch (facility.cases.length) { + case 0: + return 'There are no cases regarding this facility'; + case 1: + return 'There is one case regarding this facility'; + default: + return `There are ${facility.cases.length} cases regarding this facility`; + } + }); protected theme = signal(convertThemeName(themeSwitcher.getCurrentTheme())); protected readonly locked = signal(true); @@ -62,31 +61,25 @@ export class XdDetailPage implements OnInit { private readonly _28MinutesAgo = new Date(this._currentTime.getTime() - 28 * 60 * 1000); private readonly _detailsFacade = inject(XdDetailsFacade); protected readonly facility = toSignal(this._detailsFacade.getFacility(this._assetId)); - protected readonly pumpData = toSignal( - this._detailsFacade.getTimeSeriesDataItems(this._assetId, 'PumpData', { - from: this._28MinutesAgo, - to: this._currentTime, - }), - ); - protected readonly envData = toSignal( - this._detailsFacade.getTimeSeriesDataItems(this._assetId, 'Environment', { - from: this._28MinutesAgo, - to: this._currentTime, - }), + protected readonly pumpData = toSignal(this._detailsFacade.getPumpData(this._assetId)); + protected readonly envData = toSignal(this._detailsFacade.getEnvironmentData(this._assetId)); + protected readonly metricsData = toSignal( + this._detailsFacade.getMetrics(this._assetId, 'PumpData'), ); + private readonly defaultOptions: EChartsOption = { - tooltip: { - trigger: 'axis', - renderMode: 'auto', - axisPointer: { - axis: 'auto', - crossStyle: { - textStyle: { - precision: 2, - } - } - } - }, + tooltip: { + trigger: 'axis', + renderMode: 'auto', + axisPointer: { + axis: 'auto', + crossStyle: { + textStyle: { + precision: 2, + }, + }, + }, + }, xAxis: { type: 'time', name: 'Time', @@ -108,35 +101,35 @@ export class XdDetailPage implements OnInit { top: 80, }, }; - private readonly barChartOptions: EChartsOption = { - tooltip: { - trigger: 'axis', - renderMode: 'auto', - axisPointer: { - axis: 'auto', - crossStyle: { - textStyle: { - precision: 2, - } - } - } - }, - legend: { - top: 30, - left: 80, - }, - grid: { - top: 80, - }, - title: { - text: 'Pump Metrics', - left: 'center', - }, - yAxis: { - type: 'value', - nameLocation: 'middle', - }, - } + private readonly barChartOptions: EChartsOption = { + tooltip: { + trigger: 'axis', + renderMode: 'auto', + axisPointer: { + axis: 'auto', + crossStyle: { + textStyle: { + precision: 2, + }, + }, + }, + }, + legend: { + top: 30, + left: 80, + }, + grid: { + top: 80, + }, + title: { + text: 'Pump Metrics', + left: 'center', + }, + yAxis: { + type: 'value', + nameLocation: 'middle', + }, + }; private readonly pumpOptions: EChartsOption = { ...this.defaultOptions, title: { @@ -178,23 +171,22 @@ export class XdDetailPage implements OnInit { }; protected readonly pumpChart: Signal = computed(() => { const pumpData = this.pumpData(); - if (!pumpData) return undefined; - const pumpChart = { ...this.pumpOptions, }; + if (!pumpData) return pumpChart; + if (!pumpChart.series || !(pumpChart.series instanceof Array)) return undefined; - pumpChart.series[0].data = pumpData.map((item) => [ item.time, item['Flow'] ]); - pumpChart.series[1].data = pumpData.map((item) => [ item.time, item['MotorCurrent'] ]); + pumpChart.series[0].data = pumpData.map((item) => [item.time, item['Flow']]); + pumpChart.series[1].data = pumpData.map((item) => [item.time, item['MotorCurrent']]); pumpChart.series[2].data = pumpData.map((item) => [ item.time, item['StuffingBoxTemperature'], ]); - pumpChart.series[3].data = pumpData.map((item) => [ item.time, item['PressureIn'] ]); - pumpChart.series[4].data = pumpData.map((item) => [ item.time, item['PressureOut'] ]); - + pumpChart.series[3].data = pumpData.map((item) => [item.time, item['PressureIn']]); + pumpChart.series[4].data = pumpData.map((item) => [item.time, item['PressureOut']]); return pumpChart; }); private readonly envOptions: EChartsOption = { @@ -225,62 +217,61 @@ export class XdDetailPage implements OnInit { ], }; - protected readonly envChart: Signal = computed(() => { - const envData = this.envData(); - if (!envData) return undefined; - - const envChart = { - ...this.envOptions, - }; + protected readonly envChart: Signal = computed(() => { + const envData = this.envData(); - if (!envChart.series || !(envChart.series instanceof Array)) return undefined; + if (!envData) return undefined; - envChart.series[0].data = envData.map((item) => [ item.time, item['Temperature'] ]); - envChart.series[1].data = envData.map((item) => [ item.time, item['Humidity'] ]); - envChart.series[2].data = envData.map((item) => [ item.time, item['Pressure'] ]); - return envChart; - }); + const envChart = { + ...this.envOptions, + }; - protected readonly metricsChart: Signal = computed(() => { - const facility = this.facility(); - if (!facility) return undefined; + if (!envChart.series || !(envChart.series instanceof Array)) return undefined; - const metrics = facility.metrics; + envChart.series[0].data = envData.map((item) => [item.time, item['Temperature']]); + envChart.series[1].data = envData.map((item) => [item.time, item['Humidity']]); + envChart.series[2].data = envData.map((item) => [item.time, item['Pressure']]); + return envChart; + }); - if (!metrics || Array.isArray(metrics) && metrics.length === 0) return undefined; + protected readonly metricsChart: Signal = computed(() => { + const metricsData = this.metricsData(); - const xAxisData = map(metrics, item => PUMP_METRICS_FULL_NAME_MAP[item.name].replace(/ /g, '\n').trim()); - const seriesKeys = $enum(EMetricsCategory).getValues(); + if (!metricsData) return undefined; - const seriesData = map(seriesKeys, (key) => { - return { - name: METRIC_CATEGORY_COLOR_INFORMATION[key].abbreviation, - data: map(metrics, (item: IPumpMetrics) => parseFloat(item[key]!.toFixed(2))), - type: 'bar', - emphasis: { focus: 'series' }, - itemStyle: { color: METRIC_CATEGORY_COLOR_INFORMATION[key].color }, - }; - }); + const xAxisData = map(metricsData, (item) => + PUMP_METRICS_FULL_NAME_MAP[item.name].replace(/ /g, '\n').trim(), + ); + const seriesKeys = $enum(EMetricsCategory).getValues(); - return defaults(this.barChartOptions, { - xAxis: { - type: 'category', - data: xAxisData, - nameLocation: 'middle', - axisLabel: { - width: 100, - overflow: 'truncate', - interval: 0, - }, - }, - series: seriesData, - }); - }); + const seriesData = map(seriesKeys, (key) => { + return { + name: METRIC_CATEGORY_COLOR_INFORMATION[key].abbreviation, + data: map(metricsData, (item: IPumpMetrics) => item[key]), + type: 'bar', + emphasis: { focus: 'series' }, + itemStyle: { color: METRIC_CATEGORY_COLOR_INFORMATION[key].color }, + }; + }); + return defaults(this.barChartOptions, { + xAxis: { + type: 'category', + data: xAxisData, + nameLocation: 'middle', + axisLabel: { + width: 100, + overflow: 'truncate', + interval: 0, + }, + }, + series: seriesData, + }); + }); constructor( protected route: ActivatedRoute, - protected location: Location, + protected location: Location, private readonly _modalService: ModalService, ) {} @@ -288,7 +279,7 @@ export class XdDetailPage implements OnInit { registerTheme(echarts); themeSwitcher.themeChanged.on((theme: string) => { - this.theme.set(convertThemeName(theme)); + this.theme.set(convertThemeName(theme)); }); } @@ -305,17 +296,16 @@ export class XdDetailPage implements OnInit { }); } - mapNth(n: number) { - switch (n) { - case 1: - return 'First'; - case 2: - return 'Second'; - case 3: - return `${n}rd` - default: - return `${n}th`; - } - } - + mapNth(n: number) { + switch (n) { + case 1: + return 'First'; + case 2: + return 'Second'; + case 3: + return `${n}rd`; + default: + return `${n}th`; + } + } } diff --git a/libs/facilities/frontend/view/src/lib/directives/back-button.directive.spec.ts b/libs/facilities/frontend/view/src/lib/directives/back-button.directive.spec.ts index 28897bea..ba7fb06a 100644 --- a/libs/facilities/frontend/view/src/lib/directives/back-button.directive.spec.ts +++ b/libs/facilities/frontend/view/src/lib/directives/back-button.directive.spec.ts @@ -1,20 +1,42 @@ import { Location } from '@angular/common'; +import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { BackButtonDirective } from './back-button.directive'; +@Component({ + template: ``, +}) +class TestComponent {} + describe('BackButtonDirective', () => { - let directive: BackButtonDirective; + let location: Location; + let fixture: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ TestComponent ], + imports: [ BackButtonDirective ], + providers: [ Location ], + }); + + fixture = TestBed.createComponent(TestComponent); + location = TestBed.inject(Location); + fixture.detectChanges(); + }); + + it('should create an instance', () => { + const directive = new BackButtonDirective(location); + expect(directive).toBeTruthy(); + }); - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ Location, BackButtonDirective ] - }); + it('should call location.back() on click', () => { + jest.spyOn(location, 'back'); - directive = TestBed.inject(BackButtonDirective); - }); + const button = fixture.debugElement.query(By.directive(BackButtonDirective)); + button.triggerEventHandler('click', null); - it('should create an instance', () => { - expect(directive).toBeTruthy(); - }); + expect(location.back).toHaveBeenCalled(); + }); }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d96b9ea1..fefa8a92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,18 +37,18 @@ model TimeSeriesDataItem { } model Asset { - assetId String @id - name String - description String? - location AssetLocation? - typeId String - variables Json? - status FacilityStatus @default(REGULAR) - indicatorMsg String @default("The pump is working as expected.") + assetId String @id + name String + description String? + location AssetLocation? + typeId String + variables Json? + status FacilityStatus @default(REGULAR) + indicatorMsg String @default("The pump is working as expected.") cases Case[] timeSeriesItems TimeSeriesItem[] - metrics Metrics[] + metrics Metrics[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -97,20 +97,20 @@ model Case { } model Metrics { - id Int @id @default(autoincrement()) - min Float? - max Float? - mean Float? - variance Float? - standardDeviation Float? - coefficientOfVariation Float? - name String - - assetId String - Asset Asset @relation(fields: [assetId], references: [assetId]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + min Float? + max Float? + mean Float? + variance Float? + standardDeviation Float? + coefficientOfVariation Float? + name String + + assetId String + Asset Asset @relation(fields: [assetId], references: [assetId]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum FacilityStatus { diff --git a/prisma/seed.ts b/prisma/seed.ts index 85c27369..440a5f70 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,188 +1,242 @@ import { Prisma, PrismaClient } from '@prisma/client'; -import caseData from './demo_data/CASE-EXAMPLE.json'; +import { faker } from '@faker-js/faker'; + import pump2envData from './demo_data/PUMP-002_Environment_20240422-220000000_20240423-220000000.json'; import pump2pumpData from './demo_data/PUMP-002_PumpData_20240422-220000000_20240423-220000000.json'; - import pump10envData from './demo_data/PUMP-010_Environment_20240422-220000000_20240423-220000000.json'; import pump10pumpData from './demo_data/PUMP-010_PumpData_20240422-220000000_20240423-220000000.json'; const prisma = new PrismaClient(); -const facilityNames = [ - { name: 'Facility 1', assetId: 'Facility1', description: 'This is facility 1', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 2', assetId: 'Facility2', description: 'This is facility 2', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 3', assetId: 'Facility3', description: 'This is facility 3', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 4', assetId: 'Facility4', description: 'This is facility 4', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 5', assetId: 'Facility5', description: 'This is facility 5', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 6', assetId: 'Facility6', description: 'This is facility 6', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 7', assetId: 'Facility7', description: 'This is facility 7', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 8', assetId: 'Facility8', description: 'This is facility 8', typeId: 'pump', indicatorMsg: 'Everything works fine' }, - { name: 'Facility 9', assetId: 'Facility9', description: 'This is facility 9', typeId: 'pump', indicatorMsg: 'Everything works fine' }, +const facilityConst = [ + { name: 'Dribble & Drizzle Station', description: 'A captivating large aquarium located in the Computer Science Department building.' }, + { name: 'Waste Water Processing', description: 'Advanced facility dedicated to efficient and eco-friendly waste water treatment.' }, + { name: 'Submarine Solutions', description: 'Cutting-edge submarine testing facility focused on innovation and safety.' }, + { name: 'Gush & Flush' , description: 'Specialized overflow management services in Truth or Consequences, ensuring smooth and safe water flow.' }, + { name: 'Well, Well, Well', description: 'State-of-the-art water pump facility designed to provide sustainable and reliable access to clean water.' }, + { name: 'totally legal waste disposal', description: 'We ensure environmentally responsible and completely legitimate waste disposal services.' }, + { name: 'Drop Zone', description: 'Efficient and reliable water delivery service ensuring residents have access to clean water when they need it most.' }, + { name: 'H2-Whoa Facility', description: ' A high-tech facility known for its impressive and groundbreaking water processing solutions.' }, + { name: 'Bucket Brigade', description: 'Reliable emergency water supply service ensuring access to water during critical situations.' }, + { name: "Pump It Up Station", description: "A modern facility dedicated to high-efficiency water pumping and distribution to urban and rural areas." } ]; +faker.seed(123); + +const facilities = facilityConst.map((facility, index) => { + return { + name: facility.name, + assetId: faker.string.uuid(), + description: facility.description, + typeId: 'pump', + indicatorMsg: 'Everything works fine', + }; +}); + +const CasesConst = [ + { title: 'Pump Repair Needed', description: 'The pump is damaged and is operating at a fraction of its original capacity', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Overflow Alarm', description: 'The overflow sensor is malfunctioning and causing false alarms', type: 'INCIDENT', priority: 'MEDIUM' }, + { title: 'Leak Detection', description: 'A significant leak has been detected in the main pipeline', type: 'INCIDENT', priority: 'EMERGENCY' }, + { title: 'Pressure Drop', description: 'Sudden pressure drop in the system is affecting water distribution', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Filter Replacement', description: 'The water filters are clogged and need immediate replacement', type: 'INCIDENT', priority: 'EMERGENCY' }, + { title: 'Valve Malfunction', description: 'A critical valve is stuck and preventing proper water flow', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Contamination Alert', description: 'Water tests have shown signs of contamination in the supply', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Pump Overheating', description: 'The pump is overheating due to continuous operation and insufficient cooling', type: 'INCIDENT', priority: 'MEDIUM' }, + { title: 'Sensor Calibration', description: 'Sensors need recalibration to ensure accurate readings', type: 'PLANNED', priority: 'LOW' }, + { title: 'Electrical Fault', description: 'An electrical fault has caused a shutdown of the main control panel', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Water Hammer Issue', description: 'Experiencing water hammer effect causing pipe vibrations and noise', type: 'INCIDENT', priority: 'MEDIUM' }, + { title: 'System Upgrade', description: 'The system requires an upgrade to handle increased water demand', type: 'ANNOTATION', priority: 'MEDIUM' }, + { title: 'Backup Generator Failure', description: 'The backup generator has failed and needs urgent repair', type: 'INCIDENT', priority: 'EMERGENCY' }, + { title: 'Water Quality Testing', description: 'Routine water quality testing has detected abnormal levels of contaminants', type: 'ANNOTATION', priority: 'MEDIUM' }, + { title: 'Flow Rate Anomaly', description: 'Unexplained fluctuations in water flow rate require investigation', type: 'ANNOTATION', priority: 'LOW' }, + { title: 'Pipe Burst', description: 'A major pipe has burst, causing significant water loss', type: 'INCIDENT', priority: 'EMERGENCY' }, + { title: 'Pump Station Power Outage', description: 'A power outage has halted operations at the pump station', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Tank Overflow', description: 'A storage tank is overflowing due to faulty level sensors', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Unauthorized Access', description: 'There has been unauthorized access to the control system', type: 'INCIDENT', priority: 'HIGH' }, + { title: 'Maintenance Schedule', description: 'Regular maintenance is due for several system components', type: 'PLANNED', priority: 'LOW' } +]; + +// still requires the assetId to be added +let cases = CasesConst.map((caseItem, index) => { + return { + handle: 'AA-' + faker.number.int({ min: 1000, max: 9999 }), + dueDate: faker.date.soon({ days: 30}), + status: faker.helpers.arrayElement(['OPEN', 'OPEN', 'INPROGRESS', 'DONE', 'ARCHIVED']), + title: caseItem.title, + description: caseItem.description, + type: caseItem.type, + source: faker.helpers.arrayElement(['Internal', 'External' ]) + ' System ' + faker.number.int({min: 1, max: 10}), + priority: caseItem.priority, + createdBy: faker.internet.email(), + eTag: faker.string.alphanumeric(10), + }; +}); + async function seedSingleFacility({ - name, - assetId, - description, - typeId, - index, - indicatorMsg, -}: { - name: string; - assetId: string; - description: string; - typeId: string; - index: number; + name, + assetId, + description, + typeId, + index, + indicatorMsg, + }: { + name: string; + assetId: string; + description: string; + typeId: string; + index: number; indicatorMsg: string; }) { - const pumpData = index % 2 === 0 ? pump2pumpData : pump10pumpData; - const envData = index % 2 === 0 ? pump2envData : pump10envData; - - const asset = await prisma.asset.create({ - data: { - assetId, - name, - typeId, - description, + const pumpData = index % 2 === 0 ? pump2pumpData : pump10pumpData; + const envData = index % 2 === 0 ? pump2envData : pump10envData; + + const asset = await prisma.asset.create({ + data: { + assetId, + name, + typeId, + description, indicatorMsg, - location: { - create: { - latitude: 37.7749, - longitude: 122.4194, - country: 'United States', - region: 'California', - streetAddress: '123 Main St', - postalCode: '94105', - locality: 'San Francisco', - }, - }, - variables: {}, - }, - }); - - const tsItemPumpData = await prisma.timeSeriesItem.create({ - data: { - assetId: asset.assetId, - propertySetName: 'PumpData', - variables: [ - { - name: 'Flow', - unit: 'l/s', - }, - { - name: 'MotorCurrent', - unit: 'V', - }, - { - name: 'PressureIn', - unit: 'hPa', - }, - { - name: 'PressureOut', - unit: 'hPa', - }, - { - name: 'StuffingBoxTemperature', - unit: '°C', - }, - ], - }, - }); - - const newPumpData = pumpData.map((data: any) => { - return { - time: new Date(data._time), - - timeSeriesItemAssetId: tsItemPumpData.assetId, - timeSeriesItemPropertySetName: tsItemPumpData.propertySetName, - - data: { - motorCurrent: data.MotorCurrent, - pressureOut: data.PressureOut, - stuffingBoxTemperature: data.StuffingBoxTemperature, - pressureIn: data.PressureIn, - flow: data.Flow, - } as Prisma.JsonObject, - }; - }); - - const tSItemEnv = await prisma.timeSeriesItem.create({ - data: { - assetId: asset.assetId, - propertySetName: 'Environment', - variables: [ - { - name: 'Humidity', - unit: '%', - }, - { - name: 'Pressure', - unit: 'kPa', - }, - { - name: 'Temperature', - unit: '°C', - }, - ], - }, - }); - - const newEnvData = envData.map((data: any) => { - return { - time: new Date(data._time), - - timeSeriesItemAssetId: asset.assetId, - timeSeriesItemPropertySetName: tSItemEnv.propertySetName, - - data: { - pressureQc: data.Pressure_qc, - temperature: data.Temperature, - temperatureQc: data.Temperature_qc, - humidityQc: data.Humidity_qc, - humidity: data.Humidity, - pressure: data.Pressure, - } as Prisma.JsonObject, - }; - }); - - // Seed database with timeseries data - await prisma.timeSeriesDataItem.createMany({ - data: [newPumpData, newEnvData].flat(), - }); - - // create new case data from JSON file - const newCaseData = caseData.map((data: any) => { - return { - handle: data.handle, - dueDate: new Date(data.dueDate), // See https://stackoverflow.com/a/52823241 - title: data.title, - type: data.type, - status: data.status, - indicatorMsg: data.indicatorMsg, - description: data.description, - source: data.source, - priority: data.priority, - createdBy: data.createdBy, - eTag: data.eTag, - assetAssetId: asset.assetId, - }; - }); - - // Seed database with case data - await prisma.case.createMany({ - data: newCaseData, - }); + location: { + create: { + latitude: faker.location.latitude(), + longitude: faker.location.longitude(), + country: faker.location.country(), + region: faker.location.state(), + streetAddress: faker.location.streetAddress(), + postalCode: faker.location.zipCode(), + locality: faker.location.city(), + }, + }, + variables: {}, + }, + }); + + const tsItemPumpData = await prisma.timeSeriesItem.create({ + data: { + assetId: asset.assetId, + propertySetName: 'PumpData', + variables: [ + { + name: 'Flow', + unit: 'l/s', + }, + { + name: 'MotorCurrent', + unit: 'V', + }, + { + name: 'PressureIn', + unit: 'hPa', + }, + { + name: 'PressureOut', + unit: 'hPa', + }, + { + name: 'StuffingBoxTemperature', + unit: '°C', + }, + ], + }, + }); + + const newPumpData = pumpData.map((data: any) => { + return { + time: new Date(data._time), + + timeSeriesItemAssetId: tsItemPumpData.assetId, + timeSeriesItemPropertySetName: tsItemPumpData.propertySetName, + + data: { + MotorCurrent: data.MotorCurrent, + PressureOut: data.PressureOut, + StuffingBoxTemperature: data.StuffingBoxTemperature, + PressureIn: data.PressureIn, + Flow: data.Flow, + } as Prisma.JsonObject, + }; + }); + + const tSItemEnv = await prisma.timeSeriesItem.create({ + data: { + assetId: asset.assetId, + propertySetName: 'Environment', + variables: [ + { + name: 'Humidity', + unit: '%', + }, + { + name: 'Pressure', + unit: 'kPa', + }, + { + name: 'Temperature', + unit: '°C', + }, + ], + }, + }); + + const newEnvData = envData.map((data: any) => { + return { + time: new Date(data._time), + + timeSeriesItemAssetId: asset.assetId, + timeSeriesItemPropertySetName: tSItemEnv.propertySetName, + + data: { + PressureQc: data.Pressure_qc, + Temperature: data.Temperature, + TemperatureQc: data.Temperature_qc, + HumidityQc: data.Humidity_qc, + Humidity: data.Humidity, + Pressure: data.Pressure, + } as Prisma.JsonObject, + }; + }); + + // Seed database with timeseries data + await prisma.timeSeriesDataItem.createMany({ + data: [newPumpData, newEnvData].flat(), + }); + + // take 0-2 cases from the cases array and remove them + const caseData = faker.helpers.arrayElements(cases, { min: 0, max: 3 }) + cases = cases.filter((item) => !caseData.includes(item)); + + const newCaseData = caseData.map((data: any) => { + return { + handle: data.handle, + dueDate: data.dueDate, + title: data.title, + type: data.type, + status: data.status, + description: data.description, + source: data.source, + priority: data.priority, + createdBy: data.createdBy, + eTag: data.eTag, + assetAssetId: asset.assetId, + }; + }); + + // Seed database with case data + await prisma.case.createMany({ + data: newCaseData, + }); } async function main() { - // Seed database with facility data - for (let i = 0; i < facilityNames.length; i++) { - await seedSingleFacility({ ...facilityNames[i], index: i }); - } + // Seed database with facility data + for (let i = 0; i < facilities.length; i++) { + await seedSingleFacility({ ...facilities[i], index: i }); + } } main().catch((e) => { - console.error(e); + console.error(e); - process.exit(1); + process.exit(1); }); diff --git a/tsconfig.base.json b/tsconfig.base.json index a05240cf..21dc565b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,6 +18,9 @@ "paths": { "@frontend/cases/frontend/domain": ["libs/cases/frontend/domain/src/index.ts"], "@frontend/common/shared/models": ["libs/common/shared/models/src/index.ts"], + "@frontend/facilities/backend/metrics": [ + "libs/facilities/backend/metrics/src/index.ts" + ], "@frontend/facilities/backend/models": ["libs/facilities/backend/models/src/index.ts"], "@frontend/facilities/backend/utils": ["libs/facilities/backend/utils/src/index.ts"], "@frontend/facilities/frontend/domain": [