diff --git a/alcs-frontend/src/app/features/search/application-search-table/application-search-table.component.ts b/alcs-frontend/src/app/features/search/application-search-table/application-search-table.component.ts index 1143ab82a7..a45e8128e3 100644 --- a/alcs-frontend/src/app/features/search/application-search-table/application-search-table.component.ts +++ b/alcs-frontend/src/app/features/search/application-search-table/application-search-table.component.ts @@ -38,6 +38,7 @@ export class ApplicationSearchTableComponent { @Input() totalCount: number | undefined; @Input() statuses: ApplicationStatusDto[] = []; @Input() regions: ApplicationRegionDto[] = []; + @Input() isCommissioner: boolean = false; @Output() tableChange = new EventEmitter(); @@ -70,7 +71,9 @@ export class ApplicationSearchTableComponent { } onSelectRecord(record: SearchResult) { - const url = this.router.serializeUrl(this.router.createUrlTree([`/application/${record.referenceId}`])); + const url = this.isCommissioner + ? this.router.serializeUrl(this.router.createUrlTree([`/commissioner/application/${record.referenceId}`])) + : this.router.serializeUrl(this.router.createUrlTree([`/application/${record.referenceId}`])); window.open(url, '_blank'); } diff --git a/alcs-frontend/src/app/features/search/file-type-filter-drop-down/file-type-filter-drop-down.component.spec.ts b/alcs-frontend/src/app/features/search/file-type-filter-drop-down/file-type-filter-drop-down.component.spec.ts index 16cdd4ce9b..53a1d10d94 100644 --- a/alcs-frontend/src/app/features/search/file-type-filter-drop-down/file-type-filter-drop-down.component.spec.ts +++ b/alcs-frontend/src/app/features/search/file-type-filter-drop-down/file-type-filter-drop-down.component.spec.ts @@ -4,13 +4,26 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { FileTypeDataSourceService } from '../../../services/search/file-type/file-type-data-source.service'; import { FileTypeFilterDropDownComponent } from './file-type-filter-drop-down.component'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthenticationService, ICurrentUser } from '../../../services/authentication/authentication.service'; +import { BehaviorSubject, of } from 'rxjs'; describe('FileTypeFilterDropDownComponent', () => { let component: FileTypeFilterDropDownComponent; let fixture: ComponentFixture; + let mockAuthenticationService: DeepMocked; + let currentUser: BehaviorSubject; beforeEach(async () => { + mockAuthenticationService = createMock(); + currentUser = new BehaviorSubject(undefined); await TestBed.configureTestingModule({ + providers: [ + { + provide: AuthenticationService, + useValue: mockAuthenticationService, + }, + ], declarations: [FileTypeFilterDropDownComponent], schemas: [NO_ERRORS_SCHEMA], imports: [MatAutocompleteModule], @@ -19,7 +32,8 @@ describe('FileTypeFilterDropDownComponent', () => { fixture = TestBed.createComponent(FileTypeFilterDropDownComponent); component = fixture.componentInstance; component.label = 'Label'; - component.fileTypeData = new FileTypeDataSourceService(); + mockAuthenticationService.$currentUser = currentUser; + component.fileTypeData = new FileTypeDataSourceService(mockAuthenticationService); fixture.detectChanges(); }); diff --git a/alcs-frontend/src/app/features/search/search.component.html b/alcs-frontend/src/app/features/search/search.component.html index 74da3d680e..1e70a99fa4 100644 --- a/alcs-frontend/src/app/features/search/search.component.html +++ b/alcs-frontend/src/app/features/search/search.component.html @@ -274,7 +274,7 @@

Date Range

Search Results:

- + Applications: {{ applicationTotal }} Search Results: [statuses]="applicationStatuses" [pageIndex]="pageIndex" [regions]="regions" + [isCommissioner]="isCommissioner" (tableChange)="onTableChange($event)" >
@@ -291,7 +292,7 @@

Search Results:

- + Notice of Intent: {{ noticeOfIntentTotal }} Search Results:
- + Planning Reviews: {{ planningReviewsTotal }} Search Results: - + Notifications: {{ notificationTotal }} Search Results: - + Inquiries: {{ inquiriesTotal }} { let component: SearchComponent; @@ -23,6 +24,8 @@ describe('SearchComponent', () => { let mockApplicationService: DeepMocked; let mockNotificationStatusService: DeepMocked; let mockNOIStatusService: DeepMocked; + let mockAuthService: DeepMocked; + let currentUser: BehaviorSubject; beforeEach(async () => { mockSearchService = createMock(); @@ -31,8 +34,9 @@ describe('SearchComponent', () => { mockApplicationService = createMock(); mockNotificationStatusService = createMock(); mockNOIStatusService = createMock(); - + mockAuthService = createMock(); mockApplicationService.$applicationRegions = new BehaviorSubject([]); + currentUser = new BehaviorSubject(undefined); await TestBed.configureTestingModule({ providers: [ @@ -66,12 +70,17 @@ describe('SearchComponent', () => { provide: NoticeOfIntentSubmissionStatusService, useValue: mockNOIStatusService, }, + { + provide: AuthenticationService, + useValue: mockAuthService, + }, ], declarations: [SearchComponent], imports: [MatAutocompleteModule], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + mockAuthService.$currentUser = currentUser; fixture = TestBed.createComponent(SearchComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/alcs-frontend/src/app/features/search/search.component.ts b/alcs-frontend/src/app/features/search/search.component.ts index 63708b5777..58d0605e58 100644 --- a/alcs-frontend/src/app/features/search/search.component.ts +++ b/alcs-frontend/src/app/features/search/search.component.ts @@ -32,6 +32,7 @@ import { ToastService } from '../../services/toast/toast.service'; import { formatDateForApi } from '../../shared/utils/api-date-formatter'; import { FileTypeFilterDropDownComponent } from './file-type-filter-drop-down/file-type-filter-drop-down.component'; import { TableChange } from './search.interface'; +import { AuthenticationService, ROLES } from '../../services/authentication/authentication.service'; export const defaultStatusBackgroundColour = '#ffffff'; export const defaultStatusColour = '#313132'; @@ -108,6 +109,7 @@ export class SearchComponent implements OnInit, OnDestroy { noiStatuses: NoticeOfIntentStatusDto[] = []; isLoading = false; today = new Date(); + isCommissioner = false; constructor( private searchService: SearchService, @@ -118,6 +120,7 @@ export class SearchComponent implements OnInit, OnDestroy { private applicationService: ApplicationService, private toastService: ToastService, private titleService: Title, + private authService: AuthenticationService, public fileTypeService: FileTypeDataSourceService, public portalStatusDataService: PortalStatusDataSourceService, ) { @@ -163,6 +166,15 @@ export class SearchComponent implements OnInit, OnDestroy { this.pidControl.valueChanges.pipe(takeUntil(this.$destroy)).subscribe(() => { this.pidInvalid = this.pidControl.invalid && (this.pidControl.dirty || this.pidControl.touched); }); + + this.authService.$currentUser.subscribe((currentUser) => { + if (currentUser) { + this.isCommissioner = + currentUser.client_roles && currentUser.client_roles.length === 1 + ? currentUser.client_roles.includes(ROLES.COMMISSIONER) + : false; + } + }); } private setup() { @@ -238,6 +250,14 @@ export class SearchComponent implements OnInit, OnDestroy { getSearchParams(): SearchRequestDto { const resolutionNumberString = this.formatStringSearchParam(this.searchForm.controls.resolutionNumber.value); + let fileTypes: string[]; + + if (this.searchForm.controls.componentType.value === null) { + fileTypes = this.isCommissioner ? this.fileTypeService.getCommissionerListData() : []; + } else { + fileTypes = this.searchForm.controls.componentType.value!; + } + return { // pagination pageSize: this.itemsPerPage, @@ -268,7 +288,7 @@ export class SearchComponent implements OnInit, OnDestroy { dateDecidedTo: this.searchForm.controls.dateDecidedTo.value ? formatDateForApi(this.searchForm.controls.dateDecidedTo.value) : undefined, - fileTypes: this.searchForm.controls.componentType.value ? this.searchForm.controls.componentType.value : [], + fileTypes: fileTypes, }; } diff --git a/alcs-frontend/src/app/services/incoming-file/incomig-file.dto.ts b/alcs-frontend/src/app/services/incoming-file/incomig-file.dto.ts new file mode 100644 index 0000000000..5c13e0479c --- /dev/null +++ b/alcs-frontend/src/app/services/incoming-file/incomig-file.dto.ts @@ -0,0 +1,14 @@ +import { CardType } from '../../shared/card/card.component'; +import { AssigneeDto } from '../user/user.dto'; + +export type IncomingFileDto = { + fileNumber: string; + applicant: string; + boardCode: string; + assignee: AssigneeDto | null; + type: CardType; + highPriority: boolean; + activeDays: number; +}; + +export type IncomingFileBoardMapDto = Record; diff --git a/alcs-frontend/src/app/services/incoming-file/incoming-file.service.spec.ts b/alcs-frontend/src/app/services/incoming-file/incoming-file.service.spec.ts new file mode 100644 index 0000000000..02c2e67428 --- /dev/null +++ b/alcs-frontend/src/app/services/incoming-file/incoming-file.service.spec.ts @@ -0,0 +1,114 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { IncomingFileService } from './incoming-file.service'; +import { IncomingFileBoardMapDto } from './incomig-file.dto'; +import { CardType } from '../../shared/card/card.component'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpClient } from '@angular/common/http'; +import { ToastService } from '../toast/toast.service'; +import { of } from 'rxjs'; + +describe('ApplicationIncomingFileService', () => { + let service: IncomingFileService; + let httpClient: DeepMocked; + let toastService: DeepMocked; + + beforeEach(() => { + httpClient = createMock(); + toastService = createMock(); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MatSnackBarModule], + providers: [ + { provide: HttpClient, useValue: httpClient }, + { provide: ToastService, useValue: toastService }, + ], + }); + service = TestBed.inject(IncomingFileService); + }); + + let unsortedFiles: IncomingFileBoardMapDto = { + 'board-1': [ + { + fileNumber: '1', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.APP, + assignee: null, + highPriority: false, + activeDays: 10, + }, + { + fileNumber: '2', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.PLAN, + assignee: null, + highPriority: true, + activeDays: 12, + }, + { + fileNumber: '3', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.APP, + assignee: null, + highPriority: true, + activeDays: 30, + }, + ], + }; + + let expectedSortedFiles: IncomingFileBoardMapDto = { + 'board-1': [ + { + fileNumber: '3', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.APP, + assignee: null, + highPriority: true, + activeDays: 30, + }, + { + fileNumber: '2', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.PLAN, + assignee: null, + highPriority: true, + activeDays: 12, + }, + { + fileNumber: '1', + applicant: 'applicant', + boardCode: 'board-1', + type: CardType.APP, + assignee: null, + highPriority: false, + activeDays: 10, + }, + ], + }; + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call get for fetchingTypes', async () => { + httpClient.get.mockReturnValue(of(unsortedFiles)); + + const res = await service.fetch(); + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(Object.keys(res!).length).toEqual(1); + }); + + it('should sort the incoming data based on priority and active days', async () => { + httpClient.get.mockReturnValue(of(unsortedFiles)); + + const res = await service.fetchAndSort(); + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(res).toEqual(expectedSortedFiles); + }); +}); diff --git a/alcs-frontend/src/app/services/incoming-file/incoming-file.service.ts b/alcs-frontend/src/app/services/incoming-file/incoming-file.service.ts new file mode 100644 index 0000000000..cb08e9e943 --- /dev/null +++ b/alcs-frontend/src/app/services/incoming-file/incoming-file.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { HttpClient } from '@angular/common/http'; +import { ToastService } from '../toast/toast.service'; +import { firstValueFrom } from 'rxjs'; +import { IncomingFileBoardMapDto } from './incomig-file.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class IncomingFileService { + private url = `${environment.apiUrl}/incoming-files`; + + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} + + async fetch() { + try { + return await firstValueFrom(this.http.get(`${this.url}`)); + } catch (err) { + this.toastService.showErrorToast('Failed to fetch incoming files'); + } + return; + } + + async fetchAndSort() { + const incomingFiles = await this.fetch(); + + if (incomingFiles) { + Object.keys(incomingFiles!).forEach((board) => { + incomingFiles![board].sort((fileOne, fileTwo) => { + if (fileOne.highPriority !== fileTwo.highPriority) { + return fileOne.highPriority ? -1 : 1; + } + return fileTwo.activeDays - fileOne.activeDays; + }); + }); + + return incomingFiles; + } + + return; + } +} diff --git a/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.spec.ts b/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.spec.ts index 663703714c..ffee616ece 100644 --- a/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.spec.ts +++ b/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.spec.ts @@ -1,11 +1,26 @@ import { TestBed } from '@angular/core/testing'; import { FileTypeDataSourceService, TreeNode } from './file-type-data-source.service'; +import { AuthenticationService, ICurrentUser } from '../../authentication/authentication.service'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; describe('FileTypeDataSourceService', () => { let service: FileTypeDataSourceService; + let mockAuthenticationService: DeepMocked; + let currentUser: BehaviorSubject; beforeEach(() => { - TestBed.configureTestingModule({}); + mockAuthenticationService = createMock(); + currentUser = new BehaviorSubject(undefined); + TestBed.configureTestingModule({ + providers: [ + { + provide: AuthenticationService, + useValue: mockAuthenticationService, + }, + ], + }); + mockAuthenticationService.$currentUser = currentUser; service = TestBed.inject(FileTypeDataSourceService); }); diff --git a/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.ts b/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.ts index fd81eb8949..f51e601532 100644 --- a/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.ts +++ b/alcs-frontend/src/app/services/search/file-type/file-type-data-source.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { AuthenticationService, ROLES } from '../../authentication/authentication.service'; export interface TreeNodeItem { label: string; @@ -129,22 +130,68 @@ const TREE_DATA: TreeNode[] = [ }, ]; +const COMMISSIONER_TREE_DATA: TreeNode[] = [ + { + item: { label: 'Applications', value: null }, + children: [ + { item: { label: 'Exclusion', value: 'EXCL' } }, + { item: { label: 'Inclusion', value: 'INCL' } }, + { + item: { label: 'Non-Adhering Residential Use', value: 'NARU' }, + }, + { + item: { label: 'Non-Farm Use', value: 'NFUP' }, + }, + { + item: { label: 'Placement of Fill', value: 'POFO' }, + }, + { + item: { label: 'Removal of Soil and Placement of Fill', value: 'PFRS' }, + }, + { + item: { label: 'Removal of Soil Only', value: 'ROSO' }, + }, + { + item: { label: 'Subdivision', value: 'SUBD' }, + }, + { + item: { label: 'Transportation, Utility, Trail Permits', value: 'TURP' }, + }, + { + item: { label: 'Restrictive Covenant', value: 'COVE' }, + }, + ], + }, +]; + +const COMMISSIONER_LIST_DATA = ['EXCL', 'INCL', 'NARU', 'NFUP', 'POFO', 'PFRS', 'ROSO', 'SUBD', 'TURP', 'COVE']; + @Injectable({ providedIn: 'root' }) export class FileTypeDataSourceService { dataChange = new BehaviorSubject([]); treeData: TreeNode[] = []; + isCommissioner = false; get data(): TreeNode[] { return this.dataChange.value; } - constructor() { + constructor(public authService: AuthenticationService) { + this.authService.$currentUser.subscribe((currentUser) => { + if (currentUser) { + this.isCommissioner = + currentUser.client_roles && currentUser.client_roles.length === 1 + ? currentUser.client_roles.includes(ROLES.COMMISSIONER) + : false; + } + }); + this.initialize(); } initialize() { - this.treeData = TREE_DATA; - this.dataChange.next(TREE_DATA); + this.treeData = this.isCommissioner ? COMMISSIONER_TREE_DATA : TREE_DATA; + this.isCommissioner ? this.dataChange.next(COMMISSIONER_TREE_DATA) : this.dataChange.next(TREE_DATA); } public filter(filterText: string) { @@ -174,4 +221,8 @@ export class FileTypeDataSourceService { } this.dataChange.next(filteredTreeData); } + + public getCommissionerListData() { + return COMMISSIONER_LIST_DATA; + } } diff --git a/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.spec.ts b/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.spec.ts index 7a1ce76a95..389101c5b3 100644 --- a/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.spec.ts +++ b/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.spec.ts @@ -1,11 +1,26 @@ import { TestBed } from '@angular/core/testing'; import { PortalStatusDataSourceService, TreeNode } from './portal-status-data-source.service'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthenticationService, ICurrentUser } from '../../authentication/authentication.service'; +import { BehaviorSubject } from 'rxjs'; describe('PortalStatusDataSourceService', () => { let service: PortalStatusDataSourceService; + let mockAuthenticationService: DeepMocked; + let currentUser: BehaviorSubject; beforeEach(() => { - TestBed.configureTestingModule({}); + mockAuthenticationService = createMock(); + currentUser = new BehaviorSubject(undefined); + TestBed.configureTestingModule({ + providers: [ + { + provide: AuthenticationService, + useValue: mockAuthenticationService, + }, + ], + }); + mockAuthenticationService.$currentUser = currentUser; service = TestBed.inject(PortalStatusDataSourceService); }); diff --git a/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.ts b/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.ts index 8a046b9e08..6e60c6810c 100644 --- a/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.ts +++ b/alcs-frontend/src/app/services/search/portal-status/portal-status-data-source.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { AuthenticationService, ROLES } from '../../authentication/authentication.service'; export interface TreeNodeItem { label: string; @@ -68,22 +69,50 @@ const TREE_DATA: TreeNode[] = [ }, ]; +const COMMISSIONER_TREE_DATA: TreeNode[] = [ + { + item: { label: 'With ALC', value: null }, + children: [ + { + item: { label: 'Received by ALC', value: 'RECA' }, + }, + { + item: { label: 'Under Review by ALC', value: 'REVA' }, + }, + { + item: { label: 'Decision Released', value: 'ALCD' }, + }, + ], + }, +]; + +const COMMISSIONER_LIST_DATA = ['RECA', 'REVA', 'ALCD']; + @Injectable({ providedIn: 'root' }) export class PortalStatusDataSourceService { dataChange = new BehaviorSubject([]); treeData: TreeNode[] = []; + isCommissioner = false; get data(): TreeNode[] { return this.dataChange.value; } - constructor() { + constructor(public authService: AuthenticationService) { + this.authService.$currentUser.subscribe((currentUser) => { + if (currentUser) { + this.isCommissioner = + currentUser.client_roles && currentUser.client_roles.length === 1 + ? currentUser.client_roles.includes(ROLES.COMMISSIONER) + : false; + } + }); this.initialize(); } initialize() { - this.treeData = TREE_DATA; - this.dataChange.next(TREE_DATA); + this.treeData = this.isCommissioner ? COMMISSIONER_TREE_DATA : TREE_DATA; + this.isCommissioner ? this.dataChange.next(COMMISSIONER_TREE_DATA) : this.dataChange.next(TREE_DATA); } public filter(filterText: string) { @@ -113,4 +142,8 @@ export class PortalStatusDataSourceService { } this.dataChange.next(filteredTreeData); } + + public getCommissionerListData() { + return COMMISSIONER_LIST_DATA; + } } diff --git a/alcs-frontend/src/app/shared/header/header.component.ts b/alcs-frontend/src/app/shared/header/header.component.ts index 501ede3dec..21ebd137ff 100644 --- a/alcs-frontend/src/app/shared/header/header.component.ts +++ b/alcs-frontend/src/app/shared/header/header.component.ts @@ -65,7 +65,7 @@ export class HeaderComponent implements OnInit { const overlappingRoles = ROLES_ALLOWED_APPLICATIONS.filter((value) => currentUser.client_roles!.includes(value), ); - this.allowedSearch = overlappingRoles.length > 0; + this.allowedSearch = overlappingRoles.length > 0 || this.isCommissioner; } } }); diff --git a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.spec.ts b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.spec.ts index 8710ca56b0..7bb519c691 100644 --- a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.spec.ts +++ b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.spec.ts @@ -7,14 +7,20 @@ import { SearchService } from '../../../services/search/search.service'; import { ToastService } from '../../../services/toast/toast.service'; import { SearchBarComponent } from './search-bar.component'; +import { AuthenticationService, ICurrentUser } from '../../../services/authentication/authentication.service'; +import { BehaviorSubject } from 'rxjs'; describe('SearchBarComponent', () => { let component: SearchBarComponent; let fixture: ComponentFixture; let mockSearchService: DeepMocked; + let mockAuthenticationService: DeepMocked; + let currentUser: BehaviorSubject; beforeEach(async () => { mockSearchService = createMock(); + mockAuthenticationService = createMock(); + currentUser = new BehaviorSubject(undefined); await TestBed.configureTestingModule({ imports: [RouterTestingModule], @@ -31,11 +37,16 @@ describe('SearchBarComponent', () => { provide: SearchService, useValue: mockSearchService, }, + { + provide: AuthenticationService, + useValue: mockAuthenticationService, + }, ], declarations: [SearchBarComponent], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + mockAuthenticationService.$currentUser = currentUser; fixture = TestBed.createComponent(SearchBarComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts index 513172ba6a..bb8ab24f7f 100644 --- a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts +++ b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts @@ -1,9 +1,19 @@ import { animate, style, transition, trigger } from '@angular/animations'; import { HttpErrorResponse } from '@angular/common/http'; -import { AfterViewInit, Component, ElementRef, HostListener, QueryList, ViewChildren } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + HostListener, + OnInit, + QueryList, + ViewChildren, +} from '@angular/core'; import { Router } from '@angular/router'; import { SearchService } from '../../../services/search/search.service'; import { ToastService } from '../../../services/toast/toast.service'; +import { AuthenticationService, ROLES } from '../../../services/authentication/authentication.service'; @Component({ selector: 'app-search-bar', @@ -13,18 +23,32 @@ import { ToastService } from '../../../services/toast/toast.service'; trigger('inAnimation', [transition(':enter', [style({ height: 0, opacity: 0 }), animate('100ms ease-out')])]), ], }) -export class SearchBarComponent implements AfterViewInit { +export class SearchBarComponent implements AfterViewInit, OnInit { searchText = ''; showInput = false; wasInside = false; @ViewChildren('searchInput') input!: QueryList; + isCommissioner = false; + constructor( private toastService: ToastService, private router: Router, private searchService: SearchService, + private authService: AuthenticationService, ) {} + ngOnInit(): void { + this.authService.$currentUser.subscribe((currentUser) => { + if (currentUser) { + this.isCommissioner = + currentUser.client_roles && currentUser.client_roles.length === 1 + ? currentUser.client_roles.includes(ROLES.COMMISSIONER) + : false; + } + }); + } + @HostListener('click') clickInside() { this.wasInside = true; @@ -75,7 +99,11 @@ export class SearchBarComponent implements AfterViewInit { try { const searchResult = await this.searchService.fetch(this.searchText); if (!searchResult || searchResult.length < 1) { - this.toastService.showWarningToast(`File ID ${this.searchText} not found, try again`); + this.isCommissioner + ? this.toastService.showWarningToast( + `File ID ${this.searchText} not found. Enter an application ID and try again`, + ) + : this.toastService.showWarningToast(`File ID ${this.searchText} not found, try again`); this.selectInput(); return; } @@ -84,7 +112,9 @@ export class SearchBarComponent implements AfterViewInit { const result = searchResult[0]; switch (result.type) { case 'APP': - await this.router.navigate(['application', result.referenceId]); + this.isCommissioner + ? await this.router.navigate(['commissioner/application', result.referenceId]) + : await this.router.navigate(['application', result.referenceId]); break; case 'NOI': await this.router.navigate(['notice-of-intent', result.referenceId]); @@ -111,7 +141,11 @@ export class SearchBarComponent implements AfterViewInit { this.resetInput(); } catch (e) { if (e instanceof HttpErrorResponse && e.status === 404) { - this.toastService.showWarningToast(`File ID ${this.searchText} not found, try again`); + this.isCommissioner + ? this.toastService.showWarningToast( + `File ID ${this.searchText} not found. Enter an application ID and try again`, + ) + : this.toastService.showWarningToast(`File ID ${this.searchText} not found, try again`); this.selectInput(); } } diff --git a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.html b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.html index 28a1a85dd4..452d483d38 100644 --- a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.html +++ b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.html @@ -1,23 +1,4 @@
- @@ -33,7 +14,7 @@

@@ -91,27 +72,13 @@
{{ upcomingMeeting.meetingDate | momentFormat: customDateFormat }}
-

Scheduled Meetings

-
None Scheduled
- - - -
{{ upcomingMeeting.meetingDate | momentFormat: customDateFormat }}
-
-
- - +

Incoming

+
None
+
+ + - +

@@ -122,7 +89,7 @@
{{ upcomingMeeting.meetingDate | momentFormat: customDateFormat }}
(click)="openMeetings(meeting.fileNumber, meeting.type)" class="meeting-card" [ngClass]="{ - 'meeting-highlighted': meeting.isHighlighted + 'meeting-highlighted': meeting.isHighlighted, }" >
@@ -145,3 +112,32 @@
{{ upcomingMeeting.meetingDate | momentFormat: customDateFormat }}
+ + + +
+
+ {{ incomingFile.fileNumber }} ({{ incomingFile.applicant }}) +
+
+ +
+
+
+
diff --git a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.scss b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.scss index 7772407513..8ff4fed644 100644 --- a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.scss +++ b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.scss @@ -312,3 +312,7 @@ mat-panel-description { text-overflow: ellipsis; } } + +.incoming-files-panel { + margin-top: 16px; +} diff --git a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.spec.ts b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.spec.ts index 4a406acc01..aa37410894 100644 --- a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.spec.ts +++ b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.spec.ts @@ -13,11 +13,13 @@ import { CardType } from '../card/card.component'; import { RouterTestingModule } from '@angular/router/testing'; import { MeetingOverviewComponent } from './meeting-overview.component'; +import { IncomingFileService } from '../../services/incoming-file/incoming-file.service'; describe('MeetingOverviewComponent', () => { let component: MeetingOverviewComponent; let fixture: ComponentFixture; let mockBoardService: DeepMocked; + let mockIncomingFileService: DeepMocked; let boardEmitter: BehaviorSubject; let mockUserService: DeepMocked; let mockMeetingService: DeepMocked; @@ -25,6 +27,7 @@ describe('MeetingOverviewComponent', () => { beforeEach(async () => { mockBoardService = createMock(); + mockIncomingFileService = createMock(); boardEmitter = new BehaviorSubject([]); mockBoardService.$boards = boardEmitter; @@ -57,6 +60,10 @@ describe('MeetingOverviewComponent', () => { provide: UserService, useValue: mockUserService, }, + { + provide: IncomingFileService, + useValue: mockIncomingFileService, + }, ], declarations: [MeetingOverviewComponent], schemas: [NO_ERRORS_SCHEMA], diff --git a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.ts b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.ts index d8db0a7e37..b6583ac29a 100644 --- a/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.ts +++ b/alcs-frontend/src/app/shared/meeting-overview/meeting-overview.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import * as moment from 'moment'; +import * as moment from 'moment-timezone'; import { combineLatestWith, Subject, takeUntil } from 'rxjs'; import { ROLES } from '../../services/authentication/authentication.service'; import { BoardService, BoardWithFavourite } from '../../services/board/board.service'; @@ -8,6 +8,8 @@ import { DecisionMeetingService } from '../../services/decision-meeting/decision import { ToastService } from '../../services/toast/toast.service'; import { UserService } from '../../services/user/user.service'; import { CardType } from '../card/card.component'; +import { IncomingFileService } from '../../services/incoming-file/incoming-file.service'; +import { IncomingFileBoardMapDto, IncomingFileDto } from '../../services/incoming-file/incomig-file.dto'; import { ActivatedRoute, Router } from '@angular/router'; type MeetingCollection = { @@ -23,6 +25,7 @@ type BoardWithDecisionMeetings = { pastMeetings: MeetingCollection[]; upcomingMeetings: MeetingCollection[]; nextMeeting: MeetingCollection | undefined; + incomingFiles: IncomingFileDto[]; isExpanded: boolean; }; @@ -35,6 +38,7 @@ export class MeetingOverviewComponent implements OnInit, OnDestroy { destroy = new Subject(); private boards: BoardWithFavourite[] = []; private meetings: UpcomingMeetingBoardMapDto | undefined; + private incomingFiles: IncomingFileBoardMapDto | undefined; customDateFormat = 'ddd YYYY-MM-DD'; searchText = ''; isCommissioner = false; @@ -43,6 +47,7 @@ export class MeetingOverviewComponent implements OnInit, OnDestroy { constructor( private meetingService: DecisionMeetingService, + private incomingFileService: IncomingFileService, private boardService: BoardService, private toastService: ToastService, private userService: UserService, @@ -80,19 +85,23 @@ export class MeetingOverviewComponent implements OnInit, OnDestroy { async loadMeetings() { const meetings = await this.meetingService.fetch(); - if (meetings) { + const incomingFiles = await this.incomingFileService.fetchAndSort(); + + if (meetings && incomingFiles) { this.meetings = meetings; + this.incomingFiles = incomingFiles; await this.populateViewData(); } } private async populateViewData() { - if (this.meetings && this.boards.length > 0) { + if (this.meetings && this.incomingFiles && this.boards.length > 0) { this.viewData = this.boards .filter((board) => board.showOnSchedule) .map((board): BoardWithDecisionMeetings => { let upcomingMeetings: MeetingCollection[] = []; let pastMeetings: MeetingCollection[] = []; + const incomingFiles = this.incomingFiles![board.code]; const meetings = this.meetings![board.code]; if (meetings) { this.sortMeetings(meetings, pastMeetings, upcomingMeetings); @@ -112,6 +121,7 @@ export class MeetingOverviewComponent implements OnInit, OnDestroy { pastMeetings, upcomingMeetings, nextMeeting, + incomingFiles, isExpanded: false, }; }); @@ -124,7 +134,7 @@ export class MeetingOverviewComponent implements OnInit, OnDestroy { upcomingMeetings: MeetingCollection[], ) { meetings.forEach((app) => { - const yesterday = moment.utc().startOf('day').add(-1, 'day'); + const yesterday = moment.tz('America/Vancouver').endOf('day').add(-1, 'day'); if (yesterday.isAfter(app.meetingDate)) { this.sortMeetingsIntoCollections(pastMeetings, app); diff --git a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html index 65a6b1af19..a5f08ac19b 100644 --- a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html +++ b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html @@ -29,6 +29,7 @@

Attachments

(uploadFiles)="attachStaffReport($event)" (deleteFile)="deleteFile($event)" (openFile)="openFile($event)" + [isRequired]="true" [showErrors]="showErrors" [showVirusError]="showStaffReportVirusError" > diff --git a/services/apps/alcs/src/alcs/alcs.module.ts b/services/apps/alcs/src/alcs/alcs.module.ts index 6c9eb645b6..613aee5197 100644 --- a/services/apps/alcs/src/alcs/alcs.module.ts +++ b/services/apps/alcs/src/alcs/alcs.module.ts @@ -28,6 +28,7 @@ import { PlanningReviewTimelineModule } from './planning-review/planning-review- import { PlanningReviewModule } from './planning-review/planning-review.module'; import { SearchModule } from './search/search.module'; import { StaffJournalModule } from './staff-journal/staff-journal.module'; +import { IncomingFileModule } from './incoming-files/incoming-file.module'; @Module({ imports: [ @@ -56,6 +57,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; MeetingModule, PlanningReviewTimelineModule, MaintenanceModule, + IncomingFileModule, RouterModule.register([ { path: 'alcs', module: ApplicationModule }, { path: 'alcs', module: CommentModule }, @@ -85,6 +87,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; { path: 'alcs', module: MeetingModule }, { path: 'alcs', module: PlanningReviewTimelineModule }, { path: 'alcs', module: MaintenanceModule }, + { path: 'alcs', module: IncomingFileModule }, ]), ], controllers: [], diff --git a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts index 59d660199a..57dbb5a96d 100644 --- a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts +++ b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts @@ -131,7 +131,17 @@ export class ApplicationDecisionMeetingService { > { return await this.appDecisionMeetingRepository .createQueryBuilder('meeting') - .select('reconsideration.uuid, MAX(meeting.date) as next_meeting') + .select('reconsideration.uuid', 'uuid') + .addSelect( + ` + CASE + WHEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) is NOT NULL + THEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) + ELSE MAX(CASE WHEN meeting.date < (CURRENT_DATE) THEN meeting.date END) + END + `, + 'next_meeting', + ) .innerJoin('meeting.application', 'application') .innerJoin('application.reconsiderations', 'reconsideration') .innerJoin('reconsideration.card', 'card') @@ -145,7 +155,17 @@ export class ApplicationDecisionMeetingService { > { return await this.appDecisionMeetingRepository .createQueryBuilder('meeting') - .select('application.uuid, MAX(meeting.date) as next_meeting') + .select('application.uuid', 'uuid') + .addSelect( + ` + CASE + WHEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) is NOT NULL + THEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) + ELSE MAX(CASE WHEN meeting.date < (CURRENT_DATE) THEN meeting.date END) + END + `, + 'next_meeting', + ) .innerJoin('meeting.application', 'application') .innerJoin('application.card', 'card') .where(`card.status_code != '${CARD_STATUS.DECISION_RELEASED}'`) diff --git a/services/apps/alcs/src/alcs/application/application.service.ts b/services/apps/alcs/src/alcs/application/application.service.ts index ec0812f613..095e42a1cc 100644 --- a/services/apps/alcs/src/alcs/application/application.service.ts +++ b/services/apps/alcs/src/alcs/application/application.service.ts @@ -33,6 +33,8 @@ import { CreateApplicationServiceDto, } from './application.dto'; import { Application } from './application.entity'; +import { ApplicationDecisionMeeting } from './application-decision-meeting/application-decision-meeting.entity'; +import { CARD_STATUS } from '../card/card-status/card-status.entity'; export const APPLICATION_EXPIRATION_DAY_RANGES = { ACTIVE_DAYS_START: 55, @@ -74,6 +76,14 @@ export class ApplicationService { }; private logger = new Logger(ApplicationService.name); + private excludeStatuses = [ + CARD_STATUS.INCOMING, + CARD_STATUS.INCOMING_PRELIM_REVIEW, + CARD_STATUS.DECISION_RELEASED, + CARD_STATUS.CANCELLED, + CARD_STATUS.PRELIM_DONE, + ]; + constructor( @InjectRepository(Application) private applicationRepository: Repository, @@ -468,4 +478,81 @@ export class ApplicationService { }, ); } + + async getIncomingApplicationFiles(): Promise< + { + file_number: string; + applicant: string; + code: string; + name: string; + given_name: string; + family_name: string; + high_priority: boolean; + active_days: number; + }[] + > { + const query = ` + WITH filtered_applications AS ( + SELECT a.uuid FROM alcs.application a + LEFT JOIN alcs.application_decision_meeting adm ON adm.application_uuid = a."uuid" + INNER JOIN alcs.card c ON c."uuid" = a.card_uuid + WHERE c.status_code NOT IN (${this.excludeStatuses.map((_, index) => `$${index + 1}`).join(', ')}) + AND c.archived != TRUE + GROUP BY a.uuid + HAVING COUNT(adm.uuid) = 0 + OR COUNT(CASE WHEN adm.audit_deleted_date_at IS NULL THEN 1 END) = 0 + ), + calculated AS ( + SELECT * + FROM alcs.calculate_active_days(ARRAY(SELECT fa."uuid" FROM filtered_applications fa)) + ) + SELECT a.file_number, a.applicant, board.code, u.name, u.given_name, u.family_name, c.high_priority, calc.active_days FROM alcs.application a + INNER JOIN alcs.card c ON c."uuid" = a.card_uuid + INNER JOIN calculated calc ON a."uuid" = calc.application_uuid + INNER JOIN alcs.board board on c.board_uuid = board.uuid + LEFT JOIN alcs.user u on u.uuid = c.assignee_uuid + WHERE a.uuid IN (SELECT uuid FROM filtered_applications) + ORDER BY c.high_priority DESC, calc.active_days DESC; + `; + return await this.applicationRepository.query(query, this.excludeStatuses); + } + + async getIncomingReconsiderationFiles(): Promise< + { + file_number: string; + applicant: string; + code: string; + name: string; + given_name: string; + family_name: string; + high_priority: boolean; + active_days: number; + }[] + > { + const query = ` + WITH filtered_applications AS ( + SELECT ar.application_uuid FROM alcs.application_reconsideration ar + INNER JOIN alcs.application a on a.uuid = ar.application_uuid + LEFT JOIN alcs.application_decision_meeting adm ON adm.application_uuid = ar.application_uuid + INNER JOIN alcs.card c ON c."uuid" = ar.card_uuid + WHERE c.status_code NOT IN (${this.excludeStatuses.map((_, index) => `$${index + 1}`).join(', ')}) + AND c.archived != TRUE + GROUP BY ar.application_uuid + HAVING COUNT(adm.uuid) = 0 + OR COUNT(CASE WHEN adm.audit_deleted_date_at IS NULL THEN 1 END) = 0 + ), + calculated AS ( + SELECT * + FROM alcs.calculate_active_days(ARRAY(SELECT fa."application_uuid" FROM filtered_applications fa)) + ) + SELECT a.file_number, a.applicant, board.code, u.name, u.given_name, u.family_name, c.high_priority, calc.active_days FROM alcs.application a + INNER JOIN alcs.card c ON c."uuid" = a.card_uuid + INNER JOIN calculated calc ON a."uuid" = calc.application_uuid + INNER JOIN alcs.board board on c.board_uuid = board.uuid + LEFT JOIN alcs.user u on u.uuid = c.assignee_uuid + WHERE a.uuid IN (SELECT application_uuid FROM filtered_applications) + ORDER BY c.high_priority DESC, calc.active_days DESC; + `; + return await this.applicationRepository.query(query, this.excludeStatuses); + } } diff --git a/services/apps/alcs/src/alcs/card/card-status/card-status.entity.ts b/services/apps/alcs/src/alcs/card/card-status/card-status.entity.ts index 87c57f1cdf..504f427228 100644 --- a/services/apps/alcs/src/alcs/card/card-status/card-status.entity.ts +++ b/services/apps/alcs/src/alcs/card/card-status/card-status.entity.ts @@ -5,6 +5,9 @@ export enum CARD_STATUS { CANCELLED = 'CNCL', DECISION_RELEASED = 'RELE', READY_FOR_REVIEW = 'READ', + INCOMING = 'INCO', + INCOMING_PRELIM_REVIEW = 'INPR', + PRELIM_DONE = 'PREL', } @Entity({ diff --git a/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.spec.ts b/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.spec.ts new file mode 100644 index 0000000000..8999609d7b --- /dev/null +++ b/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IncomingFileController } from './incoming-file.controller'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { ApplicationService } from '../application/application.service'; +import { PlanningReviewService } from '../planning-review/planning-review.service'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; +import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; +import { ClsService } from 'nestjs-cls'; +import { UserProfile } from '../../common/automapper/user.automapper.profile'; + +describe('IncomingFileController', () => { + let controller: IncomingFileController; + let mockApplicationService: DeepMocked; + let mockPlanningReviewService: DeepMocked; + const CODE_ONE = 'CODE_ONE'; + const CODE_TWO = 'CODE_TWO'; + const CODE_THREE = 'CODE_THREE'; + let mockApplications = [ + { + file_number: '1', + applicant: 'applicant1', + code: CODE_ONE, + name: 'gname fname', + given_name: 'gname', + family_name: 'fname', + high_priority: true, + active_days: 10, + }, + { + file_number: '2', + applicant: 'applicant2', + code: CODE_ONE, + name: 'gname2 fname2', + given_name: 'gname2', + family_name: 'fname2', + high_priority: false, + active_days: 20, + }, + ]; + let mockReconsiderations = [ + { + file_number: '3', + applicant: 'applicant1', + code: CODE_TWO, + name: 'gname fname', + given_name: 'gname', + family_name: 'fname', + high_priority: true, + active_days: 10, + }, + { + file_number: '4', + applicant: 'applicant2', + code: CODE_TWO, + name: 'gname2 fname2', + given_name: 'gname2', + family_name: 'fname2', + high_priority: false, + active_days: 20, + }, + ]; + let mockPlanningReviews = [ + { + file_number: '5', + applicant: 'applicant1', + code: CODE_THREE, + name: 'gname fname', + given_name: 'gname', + family_name: 'fname', + high_priority: true, + active_days: 10, + }, + { + file_number: '6', + applicant: 'applicant2', + code: CODE_THREE, + name: 'gname2 fname2', + given_name: 'gname2', + family_name: 'fname2', + high_priority: false, + active_days: 20, + }, + ]; + + beforeEach(async () => { + mockApplicationService = createMock(); + mockPlanningReviewService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [IncomingFileController], + providers: [ + UserProfile, + { + provide: ApplicationService, + useValue: mockApplicationService, + }, + { + provide: PlanningReviewService, + useValue: mockPlanningReviewService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + + controller = module.get(IncomingFileController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should load and map incoming applications', async () => { + mockApplicationService.getIncomingApplicationFiles.mockResolvedValue( + mockApplications, + ); + mockApplicationService.getIncomingReconsiderationFiles.mockResolvedValue( + [], + ); + mockPlanningReviewService.getIncomingPlanningReviewFiles.mockResolvedValue( + [], + ); + + const res = await controller.getIncomingFiles(); + + expect(Object.keys(res).length).toEqual(1); + expect(res.CODE_ONE).toBeDefined(); + expect(res.CODE_ONE.length).toEqual(2); + expect(res.CODE_ONE[0].highPriority).toEqual(true); + expect(res.CODE_ONE[1].activeDays).toEqual(20); + }); + + it('should load and map incoming reconsiderations', async () => { + mockApplicationService.getIncomingApplicationFiles.mockResolvedValue([]); + mockApplicationService.getIncomingReconsiderationFiles.mockResolvedValue( + mockReconsiderations, + ); + mockPlanningReviewService.getIncomingPlanningReviewFiles.mockResolvedValue( + [], + ); + + const res = await controller.getIncomingFiles(); + + expect(Object.keys(res).length).toEqual(1); + expect(res.CODE_TWO).toBeDefined(); + expect(res.CODE_TWO.length).toEqual(2); + expect(res.CODE_TWO[0].highPriority).toEqual(true); + expect(res.CODE_TWO[1].activeDays).toEqual(20); + }); + + it('should load and map incoming planning reviews', async () => { + mockApplicationService.getIncomingApplicationFiles.mockResolvedValue([]); + mockApplicationService.getIncomingReconsiderationFiles.mockResolvedValue( + [], + ); + mockPlanningReviewService.getIncomingPlanningReviewFiles.mockResolvedValue( + mockPlanningReviews, + ); + + const res = await controller.getIncomingFiles(); + + expect(Object.keys(res).length).toEqual(1); + expect(res.CODE_THREE).toBeDefined(); + expect(res.CODE_THREE.length).toEqual(2); + expect(res.CODE_THREE[0].highPriority).toEqual(true); + expect(res.CODE_THREE[1].activeDays).toEqual(20); + }); + + it('should load and map applications, reconsiderations, and planning reviews', async () => { + mockApplicationService.getIncomingApplicationFiles.mockResolvedValue( + mockApplications, + ); + mockApplicationService.getIncomingReconsiderationFiles.mockResolvedValue( + mockReconsiderations, + ); + mockPlanningReviewService.getIncomingPlanningReviewFiles.mockResolvedValue( + mockPlanningReviews, + ); + + const res = await controller.getIncomingFiles(); + + expect(Object.keys(res).length).toEqual(3); + expect(res.CODE_ONE).toBeDefined(); + expect(res.CODE_ONE.length).toEqual(2); + expect(res.CODE_ONE[0].highPriority).toEqual(true); + expect(res.CODE_ONE[1].activeDays).toEqual(20); + expect(res.CODE_TWO).toBeDefined(); + expect(res.CODE_TWO.length).toEqual(2); + expect(res.CODE_TWO[0].highPriority).toEqual(true); + expect(res.CODE_TWO[1].activeDays).toEqual(20); + expect(res.CODE_THREE).toBeDefined(); + expect(res.CODE_THREE.length).toEqual(2); + expect(res.CODE_THREE[0].highPriority).toEqual(true); + expect(res.CODE_THREE[1].activeDays).toEqual(20); + }); +}); diff --git a/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.ts b/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.ts new file mode 100644 index 0000000000..1fbd4d0262 --- /dev/null +++ b/services/apps/alcs/src/alcs/incoming-files/incoming-file.controller.ts @@ -0,0 +1,106 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import * as config from 'config'; +import { RolesGuard } from '../../common/authorization/roles-guard.service'; +import { ApplicationService } from '../application/application.service'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { UserRoles } from '../../common/authorization/roles.decorator'; +import { ANY_AUTH_ROLE } from '../../common/authorization/roles'; +import { IncomingFileBoardMapDto, IncomingFileDto } from './incoming-file.dto'; +import { UserDto } from '../../user/user.dto'; +import { CARD_TYPE } from '../card/card-type/card-type.entity'; +import { User } from '../../user/user.entity'; +import { PlanningReviewService } from '../planning-review/planning-review.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@Controller('incoming-files') +@UseGuards(RolesGuard) +export class IncomingFileController { + constructor( + private applicationService: ApplicationService, + private planningReviewService: PlanningReviewService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('') + @UserRoles(...ANY_AUTH_ROLE) + async getIncomingFiles(): Promise { + const mappedApps = await this.getMappedIncomingApplications(); + const mappedReconsiderations = + await this.getMappedIncomingReconsiderations(); + const mappedPlanningReviews = await this.getMappedIncomingPlanningReviews(); + + const boardCodeToFiles: IncomingFileBoardMapDto = {}; + [ + ...mappedApps, + ...mappedReconsiderations, + ...mappedPlanningReviews, + ].forEach((mappedFile) => { + const boardFiles = boardCodeToFiles[mappedFile.boardCode] || []; + boardFiles.push(mappedFile); + boardCodeToFiles[mappedFile.boardCode] = boardFiles; + }); + return boardCodeToFiles; + } + + private async getMappedIncomingApplications() { + const incomingApplications = + await this.applicationService.getIncomingApplicationFiles(); + return incomingApplications.map((incomingFile): IncomingFileDto => { + const user = new User(); + user.name = incomingFile.name; + user.givenName = incomingFile.given_name; + user.familyName = incomingFile.family_name; + return { + fileNumber: incomingFile.file_number, + applicant: incomingFile.applicant, + boardCode: incomingFile.code, + type: CARD_TYPE.APP, + assignee: this.mapper.map(user, User, UserDto), + highPriority: incomingFile.high_priority, + activeDays: incomingFile.active_days, + }; + }); + } + + private async getMappedIncomingReconsiderations() { + const incomingReconsiderations = + await this.applicationService.getIncomingReconsiderationFiles(); + return incomingReconsiderations.map((incomingFile): IncomingFileDto => { + const user = new User(); + user.name = incomingFile.name; + user.givenName = incomingFile.given_name; + user.familyName = incomingFile.family_name; + return { + fileNumber: incomingFile.file_number, + applicant: incomingFile.applicant, + boardCode: incomingFile.code, + type: CARD_TYPE.APP, + assignee: this.mapper.map(user, User, UserDto), + highPriority: incomingFile.high_priority, + activeDays: incomingFile.active_days, + }; + }); + } + + private async getMappedIncomingPlanningReviews() { + const incomingPlanningReviews = + await this.planningReviewService.getIncomingPlanningReviewFiles(); + return incomingPlanningReviews.map((incomingFile): IncomingFileDto => { + const user = new User(); + user.name = incomingFile.name; + user.givenName = incomingFile.given_name; + user.familyName = incomingFile.family_name; + return { + fileNumber: incomingFile.file_number, + applicant: incomingFile.applicant, + boardCode: incomingFile.code, + type: CARD_TYPE.PLAN, + assignee: this.mapper.map(user, User, UserDto), + highPriority: incomingFile.high_priority, + activeDays: incomingFile.active_days, + }; + }); + } +} diff --git a/services/apps/alcs/src/alcs/incoming-files/incoming-file.dto.ts b/services/apps/alcs/src/alcs/incoming-files/incoming-file.dto.ts new file mode 100644 index 0000000000..2f232d4e51 --- /dev/null +++ b/services/apps/alcs/src/alcs/incoming-files/incoming-file.dto.ts @@ -0,0 +1,13 @@ +import { UserDto } from '../../user/user.dto'; + +export type IncomingFileDto = { + fileNumber: string; + applicant: string; + boardCode: string; + type: string; + assignee: UserDto; + highPriority: boolean; + activeDays: number; +}; + +export type IncomingFileBoardMapDto = Record; diff --git a/services/apps/alcs/src/alcs/incoming-files/incoming-file.module.ts b/services/apps/alcs/src/alcs/incoming-files/incoming-file.module.ts new file mode 100644 index 0000000000..bf5644ace9 --- /dev/null +++ b/services/apps/alcs/src/alcs/incoming-files/incoming-file.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { IncomingFileController } from './incoming-file.controller'; +import { ApplicationModule } from '../application/application.module'; +import { PlanningReviewModule } from '../planning-review/planning-review.module'; + +@Module({ + imports: [ApplicationModule, PlanningReviewModule], + providers: [], + controllers: [IncomingFileController], + exports: [], +}) +export class IncomingFileModule {} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-meeting/planning-review-meeting.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-meeting/planning-review-meeting.service.ts index 82ebe6821c..3df8e7b7d2 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review-meeting/planning-review-meeting.service.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-meeting/planning-review-meeting.service.ts @@ -102,7 +102,17 @@ export class PlanningReviewMeetingService { > { return await this.meetingRepository .createQueryBuilder('meeting') - .select('planningReview.uuid, MAX(meeting.date) as next_meeting') + .select('planningReview.uuid', 'uuid') + .addSelect( + ` + CASE + WHEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) is NOT NULL + THEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE) THEN meeting.date END) + ELSE MAX(CASE WHEN meeting.date < (CURRENT_DATE) THEN meeting.date END) + END + `, + 'next_meeting', + ) .innerJoin('meeting.planningReview', 'planningReview') .innerJoin('planningReview.referrals', 'referrals') .innerJoin('referrals.card', 'card') diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts index 60a6740329..f44dc1ee64 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts @@ -21,6 +21,7 @@ import { UpdatePlanningReviewDto, } from './planning-review.dto'; import { PlanningReview } from './planning-review.entity'; +import { CARD_STATUS } from '../card/card-status/card-status.entity'; @Injectable() export class PlanningReviewService { @@ -44,6 +45,14 @@ export class PlanningReviewService { type: true, }; + private excludeStatuses = [ + CARD_STATUS.INCOMING, + CARD_STATUS.INCOMING_PRELIM_REVIEW, + CARD_STATUS.DECISION_RELEASED, + CARD_STATUS.CANCELLED, + CARD_STATUS.PRELIM_DONE, + ]; + async create(data: CreatePlanningReviewDto, board: Board) { const fileNumber = await this.fileNumberService.generateNextFileNumber(); const type = await this.typeRepository.findOneOrFail({ @@ -197,4 +206,40 @@ export class PlanningReviewService { select: ['fileNumber'], }); } + + async getIncomingPlanningReviewFiles(): Promise< + { + file_number: string; + applicant: string; + code: string; + name: string; + given_name: string; + family_name: string; + high_priority: boolean; + active_days: number; + }[] + > { + const query = ` + WITH filtered_planning_reviews AS ( + SELECT pr.uuid FROM alcs.planning_review pr + INNER JOIN alcs.planning_referral prf ON prf.planning_review_uuid = pr.uuid + LEFT JOIN alcs.planning_review_meeting prm ON prm.planning_review_uuid = pr."uuid" + INNER JOIN alcs.card c ON c."uuid" = prf.card_uuid + WHERE c.status_code NOT IN (${this.excludeStatuses.map((_, index) => `$${index + 1}`).join(', ')}) + AND c.archived != TRUE + GROUP BY pr.uuid + HAVING COUNT(prm.uuid) = 0 + OR COUNT(CASE WHEN prm.audit_deleted_date_at IS NULL THEN 1 END) = 0 + ) + SELECT pr.file_number, pr.document_name as applicant, board.code, u.name, u.given_name, u.family_name, c.high_priority, 0 as active_days from alcs.planning_review pr + INNER JOIN filtered_planning_reviews fpr on fpr.uuid = pr.uuid + INNER JOIN alcs.planning_referral prf on prf.planning_review_uuid = pr.uuid + INNER JOIN alcs.card c ON c."uuid" = prf.card_uuid + INNER JOIN alcs.board board on c.board_uuid = board.uuid + LEFT JOIN alcs.user u on u.uuid = c.assignee_uuid + WHERE board.code = 'exec' + ORDER BY c.high_priority desc; + `; + return await this.reviewRepository.query(query, this.excludeStatuses); + } } diff --git a/services/apps/alcs/src/alcs/search/search.controller.ts b/services/apps/alcs/src/alcs/search/search.controller.ts index a6ae7f66b6..75e80e083b 100644 --- a/services/apps/alcs/src/alcs/search/search.controller.ts +++ b/services/apps/alcs/src/alcs/search/search.controller.ts @@ -5,7 +5,10 @@ import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; import * as config from 'config'; import { DataSource, Repository } from 'typeorm'; -import { ROLES_ALLOWED_APPLICATIONS } from '../../common/authorization/roles'; +import { + ROLES_ALLOWED_APPLICATIONS, + ROLES_ALLOWED_SEARCH, +} from '../../common/authorization/roles'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; import { UserRoles } from '../../common/authorization/roles.decorator'; import { APPLICATION_SUBMISSION_TYPES } from '../../portal/pdf-generation/generate-submission-document.service'; @@ -61,7 +64,7 @@ export class SearchController { private dataSource: DataSource, ) {} - @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + @UserRoles(...ROLES_ALLOWED_SEARCH) @Get('/:searchTerm') async search(@Param('searchTerm') searchTerm) { const application = await this.searchService.getApplication(searchTerm); @@ -115,7 +118,7 @@ export class SearchController { } @Post('/advanced') - @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + @UserRoles(...ROLES_ALLOWED_SEARCH) async advancedSearch(@Body() searchDto: SearchRequestDto) { const { searchApplications, diff --git a/services/apps/alcs/src/common/authorization/roles.ts b/services/apps/alcs/src/common/authorization/roles.ts index 110827be84..1eeb21cb35 100644 --- a/services/apps/alcs/src/common/authorization/roles.ts +++ b/services/apps/alcs/src/common/authorization/roles.ts @@ -23,3 +23,7 @@ export const ROLES_ALLOWED_ARCHIVE = [ AUTH_ROLE.APP_SPECIALIST, ]; export const ANY_AUTH_ROLE = Object.values(AUTH_ROLE); +export const ROLES_ALLOWED_SEARCH = [ + ...ROLES_ALLOWED_APPLICATIONS, + AUTH_ROLE.COMMISSIONER, +]; diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1725902876425-fix_chase_document_upload_dates.ts b/services/apps/alcs/src/providers/typeorm/migrations/1725902876425-fix_chase_document_upload_dates.ts new file mode 100644 index 0000000000..9311145365 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1725902876425-fix_chase_document_upload_dates.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixChaseDocumentUploadDates1725902876425 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(` + DO $$ + BEGIN + IF EXISTS (SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'oats') THEN + update alcs."document" d + set uploaded_at = od.uploaded_date at time zone 'America/Vancouver' + from alcs.inquiry_document id + join oats.oats_documents od on od.document_id::text = id.oats_document_id + where id.document_uuid = d."uuid" + and id.oats_issue_id in ('50879', '51298', '52731'); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // N/A + } +}