From 94d5be5db2704ea8a03e6801073c928feeb1bbd2 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Mon, 25 Sep 2023 14:59:01 -0700 Subject: [PATCH] Base Views for Public Search * Add Public Module with Search * Add new views with public filters --- .../application-advanced-search.service.ts | 2 - .../apps/alcs/src/portal/portal.module.ts | 2 + .../alcs/src/portal/public/public.module.ts | 14 + .../public-application-search-view.entity.ts | 120 +++++++ .../public-application-search.service.spec.ts | 110 +++++++ .../public-application-search.service.ts | 305 ++++++++++++++++++ ...lic-notice-of-intent-search-view.entity.ts | 120 +++++++ ...ic-notice-of-intent-search.service.spec.ts | 108 +++++++ .../public-notice-of-intent-search.service.ts | 254 +++++++++++++++ .../public-notification-search-view.entity.ts | 89 +++++ ...public-notification-search.service.spec.ts | 107 ++++++ .../public-notification-search.service.ts | 215 ++++++++++++ .../search/public-search.controller.spec.ts | 243 ++++++++++++++ .../public/search/public-search.controller.ts | 271 ++++++++++++++++ .../portal/public/search/public-search.dto.ts | 129 ++++++++ .../public/search/public-search.module.ts | 38 +++ .../search/public-search.service.spec.ts | 115 +++++++ .../public/search/public-search.service.ts | 52 +++ .../1695677471273-public_search_views.ts | 68 ++++ 19 files changed, 2360 insertions(+), 2 deletions(-) create mode 100644 services/apps/alcs/src/portal/public/public.module.ts create mode 100644 services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts create mode 100644 services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts create mode 100644 services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts create mode 100644 services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts create mode 100644 services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts create mode 100644 services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts create mode 100644 services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts create mode 100644 services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts create mode 100644 services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.controller.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.dto.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.module.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.service.spec.ts create mode 100644 services/apps/alcs/src/portal/public/search/public-search.service.ts create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1695677471273-public_search_views.ts diff --git a/services/apps/alcs/src/alcs/search/application/application-advanced-search.service.ts b/services/apps/alcs/src/alcs/search/application/application-advanced-search.service.ts index 77269de13d..76a2040f26 100644 --- a/services/apps/alcs/src/alcs/search/application/application-advanced-search.service.ts +++ b/services/apps/alcs/src/alcs/search/application/application-advanced-search.service.ts @@ -331,8 +331,6 @@ export class ApplicationAdvancedSearchService { searchDto: SearchRequestDto, query, ) { - query = query; - if (searchDto.fileTypes.length > 0) { // if decision is not joined yet -> join it. The join of decision happens in compileApplicationDecisionSearchQuery if ( diff --git a/services/apps/alcs/src/portal/portal.module.ts b/services/apps/alcs/src/portal/portal.module.ts index f7b1ebb88a..15939b5f8b 100644 --- a/services/apps/alcs/src/portal/portal.module.ts +++ b/services/apps/alcs/src/portal/portal.module.ts @@ -20,6 +20,7 @@ import { PortalNotificationDocumentModule } from './notification-document/notifi import { ParcelModule } from './parcel/parcel.module'; import { PdfGenerationModule } from './pdf-generation/pdf-generation.module'; import { NotificationSubmissionModule } from './notification-submission/notification-submission.module'; +import { PublicModule } from './public/public.module'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { NotificationSubmissionModule } from './notification-submission/notifica PortalNoticeOfIntentDecisionModule, NotificationSubmissionModule, PortalNotificationDocumentModule, + PublicModule, RouterModule.register([ { path: 'portal', module: ApplicationSubmissionModule }, { path: 'portal', module: NoticeOfIntentSubmissionModule }, diff --git a/services/apps/alcs/src/portal/public/public.module.ts b/services/apps/alcs/src/portal/public/public.module.ts new file mode 100644 index 0000000000..a08600dd31 --- /dev/null +++ b/services/apps/alcs/src/portal/public/public.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { RouterModule } from '@nestjs/core'; +import { PublicSearchModule } from './search/public-search.module'; + +@Module({ + imports: [ + PublicSearchModule, + RouterModule.register([{ path: 'public', module: PublicSearchModule }]), + ], + controllers: [], + providers: [], + exports: [], +}) +export class PublicModule {} diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts new file mode 100644 index 0000000000..0c49669b0a --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts @@ -0,0 +1,120 @@ +import { + DataSource, + JoinColumn, + ManyToOne, + PrimaryColumn, + ViewColumn, + ViewEntity, +} from 'typeorm'; +import { ApplicationDecision } from '../../../../alcs/application-decision/application-decision.entity'; +import { ApplicationSubmissionToSubmissionStatus } from '../../../../alcs/application/application-submission-status/submission-status.entity'; +import { Application } from '../../../../alcs/application/application.entity'; +import { ApplicationType } from '../../../../alcs/code/application-code/application-type/application-type.entity'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { ApplicationSubmission } from '../../../application-submission/application-submission.entity'; +import { LinkedStatusType } from '../public-search.dto'; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .createQueryBuilder() + .select('app_sub.uuid', 'uuid') + .addSelect('app_sub.file_number', 'file_number') + .addSelect('app_sub.applicant', 'applicant') + .addSelect('app_sub.local_government_uuid', 'local_government_uuid') + .addSelect('localGovernment.name', 'local_government_name') + .addSelect('app_sub.type_code', 'application_type_code') + .addSelect('app.date_submitted_to_alc', 'date_submitted_to_alc') + .addSelect('app.decision_date', 'decision_date') + .addSelect('app.uuid', 'application_uuid') + .addSelect('app.region_code', 'application_region_code') + .addSelect( + 'GREATEST(status_link.effective_date, decision_date.date)', + 'last_update', + ) + .addSelect( + 'alcs.get_current_status_for_application_submission_by_uuid(app_sub.uuid)', + 'status', + ) + .from(ApplicationSubmission, 'app_sub') + .innerJoin(Application, 'app', 'app.file_number = app_sub.file_number') + .innerJoinAndSelect( + ApplicationType, + 'applicationType', + 'app_sub.type_code = applicationType.code', + ) + .leftJoin( + LocalGovernment, + 'localGovernment', + 'app_sub.local_government_uuid = localGovernment.uuid', + ) + .leftJoin( + (qb) => + qb + .from(ApplicationSubmissionToSubmissionStatus, 'status_link') + .select('MAX("effective_date")', 'effective_date') + .addSelect('submission_uuid', 'submission_uuid') + .groupBy('submission_uuid'), + 'status_link', + 'status_link."submission_uuid" = app_sub.uuid', + ) + .leftJoin( + (qb) => + qb + .from(ApplicationDecision, 'decision_date') + .select('MAX("date")', 'date') + .addSelect('application_uuid', 'application_uuid') + .groupBy('application_uuid'), + 'decision_date', + 'decision_date."application_uuid" = app.uuid', + ) + .where('app_sub.is_draft = FALSE') + .andWhere('app.date_received_all_items IS NOT NULL') + .andWhere( + "alcs.get_current_status_for_application_submission_by_uuid(app_sub.uuid)->>'status_type_code' != 'CNCL'", + ), +}) +export class PublicApplicationSubmissionSearchView { + @ViewColumn() + @PrimaryColumn() + uuid: string; + + @ViewColumn() + applicationUuid: string; + + @ViewColumn() + lastUpdate: Date; + + @ViewColumn() + applicationRegionCode?: string; + + @ViewColumn() + fileNumber: string; + + @ViewColumn() + applicant?: string; + + @ViewColumn() + localGovernmentUuid?: string; + + @ViewColumn() + localGovernmentName?: string; + + @ViewColumn() + applicationTypeCode: string; + + @ViewColumn() + status: LinkedStatusType; + + @ViewColumn() + dateSubmittedToAlc: Date | null; + + @ViewColumn() + decisionDate: Date | null; + + @ManyToOne(() => ApplicationType, { + nullable: false, + }) + @JoinColumn({ name: 'application_type_code' }) + applicationType: ApplicationType; +} diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts new file mode 100644 index 0000000000..308db43c78 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts @@ -0,0 +1,110 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { SearchRequestDto } from '../public-search.dto'; +import { PublicApplicationSearchService } from './public-application-search.service'; +import { PublicApplicationSubmissionSearchView } from './public-application-search-view.entity'; + +describe('PublicApplicationSearchService', () => { + let service: PublicApplicationSearchService; + let mockApplicationSubmissionSearchViewRepository: DeepMocked< + Repository + >; + let mockLocalGovernmentRepository: DeepMocked>; + + const mockSearchRequestDto: SearchRequestDto = { + fileNumber: '123', + portalStatusCode: 'A', + governmentName: 'B', + regionCode: 'C', + name: 'D', + pid: 'E', + civicAddress: 'F', + dateDecidedFrom: new Date('2020-11-10').getTime(), + dateDecidedTo: new Date('2021-11-10').getTime(), + fileTypes: ['type1', 'type2'], + page: 1, + pageSize: 10, + sortField: 'ownerName', + sortDirection: 'ASC', + }; + + let mockQuery: any = {}; + + beforeEach(async () => { + mockApplicationSubmissionSearchViewRepository = createMock(); + mockLocalGovernmentRepository = createMock(); + + mockQuery = { + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + innerJoinAndMapOne: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PublicApplicationSearchService, + { + provide: getRepositoryToken(PublicApplicationSubmissionSearchView), + useValue: mockApplicationSubmissionSearchViewRepository, + }, + { + provide: getRepositoryToken(LocalGovernment), + useValue: mockLocalGovernmentRepository, + }, + ], + }).compile(); + + service = module.get( + PublicApplicationSearchService, + ); + + mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( + new LocalGovernment(), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should successfully build a query using all search parameters defined', async () => { + mockApplicationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery as any, + ); + + const result = await service.searchApplications(mockSearchRequestDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect( + mockApplicationSubmissionSearchViewRepository.createQueryBuilder, + ).toBeCalledTimes(1); + expect(mockQuery.andWhere).toBeCalledTimes(8); + expect(mockQuery.where).toBeCalledTimes(1); + }); + + it('should call compileApplicationSearchQuery method correctly', async () => { + const compileApplicationSearchQuerySpy = jest + .spyOn(service as any, 'compileApplicationSearchQuery') + .mockResolvedValue(mockQuery); + + const result = await service.searchApplications(mockSearchRequestDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect(compileApplicationSearchQuerySpy).toBeCalledWith( + mockSearchRequestDto, + ); + expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); + expect(mockQuery.offset).toHaveBeenCalledTimes(1); + expect(mockQuery.limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts new file mode 100644 index 0000000000..9c6597ac31 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts @@ -0,0 +1,305 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, Repository } from 'typeorm'; +import { ApplicationDecisionComponent } from '../../../../alcs/application-decision/application-decision-v2/application-decision/component/application-decision-component.entity'; +import { ApplicationDecision } from '../../../../alcs/application-decision/application-decision.entity'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { ApplicationOwner } from '../../../application-submission/application-owner/application-owner.entity'; +import { ApplicationParcel } from '../../../application-submission/application-parcel/application-parcel.entity'; +import { + AdvancedSearchResultDto, + SearchRequestDto, +} from '../public-search.dto'; +import { PublicApplicationSubmissionSearchView } from './public-application-search-view.entity'; + +@Injectable() +export class PublicApplicationSearchService { + constructor( + @InjectRepository(PublicApplicationSubmissionSearchView) + private applicationSearchRepository: Repository, + @InjectRepository(LocalGovernment) + private governmentRepository: Repository, + ) {} + + async searchApplications( + searchDto: SearchRequestDto, + ): Promise> { + let query = await this.compileApplicationSearchQuery(searchDto); + + query = this.compileApplicationGroupBySearchQuery(query); + + const sortQuery = this.compileSortQuery(searchDto); + + query = query + .orderBy(sortQuery, searchDto.sortDirection) + .offset((searchDto.page - 1) * searchDto.pageSize) + .limit(searchDto.pageSize); + + const result = await query.getManyAndCount(); + + return { + data: result[0], + total: result[1], + }; + } + + private compileSortQuery(searchDto: SearchRequestDto) { + switch (searchDto.sortField) { + case 'fileId': + return '"appSearch"."file_number"'; + + case 'ownerName': + return '"appSearch"."applicant"'; + + case 'type': + return '"appSearch"."application_type_code"'; + + case 'government': + return '"appSearch"."local_government_name"'; + + case 'portalStatus': + return `"appSearch"."status" ->> 'label' `; + + default: + case 'dateSubmitted': + return '"appSearch"."date_submitted_to_alc"'; + } + } + + private compileApplicationGroupBySearchQuery(query) { + query = query + .innerJoinAndMapOne( + 'appSearch.applicationType', + 'appSearch.applicationType', + 'applicationType', + ) + .groupBy( + ` + "appSearch"."uuid" + , "appSearch"."application_uuid" + , "appSearch"."application_region_code" + , "appSearch"."file_number" + , "appSearch"."applicant" + , "appSearch"."local_government_uuid" + , "appSearch"."local_government_name" + , "appSearch"."application_type_code" + , "appSearch"."status" + , "appSearch"."date_submitted_to_alc" + , "appSearch"."decision_date" + , "applicationType"."audit_deleted_date_at" + , "applicationType"."audit_created_at" + , "applicationType"."audit_updated_by" + , "applicationType"."audit_updated_at" + , "applicationType"."audit_created_by" + , "applicationType"."short_label" + , "applicationType"."label" + , "applicationType"."code" + , "applicationType"."background_color" + , "applicationType"."text_color" + , "applicationType"."html_description" + , "applicationType"."portal_label" + `, + ); + return query; + } + + private async compileApplicationSearchQuery(searchDto: SearchRequestDto) { + let query = this.applicationSearchRepository + .createQueryBuilder('appSearch') + .where('appSearch.is_draft = false'); + + if (searchDto.fileNumber) { + query = query + .andWhere('appSearch.file_number = :fileNumber') + .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + } + + if (searchDto.portalStatusCode) { + query = query.andWhere( + "alcs.get_current_status_for_application_submission_by_uuid(appSearch.uuid) ->> 'status_type_code' = :status", + { + status: searchDto.portalStatusCode, + }, + ); + } + + if (searchDto.governmentName) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); + + query = query.andWhere( + 'appSearch.local_government_uuid = :local_government_uuid', + { + local_government_uuid: government.uuid, + }, + ); + } + + if (searchDto.regionCode) { + query = query.andWhere( + 'appSearch.application_region_code = :application_region_code', + { + application_region_code: searchDto.regionCode, + }, + ); + } + + query = this.compileApplicationSearchByNameQuery(searchDto, query); + query = this.compileApplicationParcelSearchQuery(searchDto, query); + query = this.compileApplicationDecisionSearchQuery(searchDto, query); + query = this.compileApplicationFileTypeSearchQuery(searchDto, query); + + return query; + } + + private compileApplicationDecisionSearchQuery( + searchDto: SearchRequestDto, + query, + ) { + if ( + searchDto.dateDecidedTo !== undefined || + searchDto.dateDecidedFrom !== undefined + ) { + query = this.joinApplicationDecision(query); + // TODO + // if (searchDto.resolutionNumber !== undefined) { + // query = query.andWhere( + // 'decision.resolution_number = :resolution_number', + // { + // resolution_number: searchDto.resolutionNumber, + // }, + // ); + // } + // + // if (searchDto.resolutionYear !== undefined) { + // query = query.andWhere('decision.resolution_year = :resolution_year', { + // resolution_year: searchDto.resolutionYear, + // }); + // } + } + return query; + } + + private joinApplicationDecision(query: any) { + query = query.leftJoin( + ApplicationDecision, + 'decision', + 'decision.application_uuid = "appSearch"."application_uuid"', + ); + return query; + } + + private compileApplicationParcelSearchQuery( + searchDto: SearchRequestDto, + query, + ) { + if (searchDto.pid || searchDto.civicAddress) { + query = query.leftJoin( + ApplicationParcel, + 'parcel', + "parcel.application_submission_uuid = appSearch.uuid AND parcel.parcel_type IN ('application', 'other')", + ); + } else { + query = query.leftJoin( + ApplicationParcel, + 'parcel', + "parcel.application_submission_uuid = appSearch.uuid AND parcel.parcel_type = 'application'", + ); + } + + if (searchDto.pid) { + query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); + } + + if (searchDto.civicAddress) { + query = query.andWhere('parcel.civic_address like :civic_address', { + civic_address: `%${searchDto.civicAddress}%`, + }); + } + return query; + } + + private compileApplicationSearchByNameQuery( + searchDto: SearchRequestDto, + query, + ) { + if (searchDto.name) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + + query = query + .leftJoin( + ApplicationOwner, + 'application_owner', + 'application_owner.application_submission_uuid = appSearch.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(application_owner.first_name || ' ' || application_owner.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(application_owner.first_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere('LOWER(application_owner.last_name) LIKE ANY (:names)', { + names: formattedSearchString, + }) + .orWhere( + 'LOWER(application_owner.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ); + } + return query; + } + + private compileApplicationFileTypeSearchQuery( + searchDto: SearchRequestDto, + query, + ) { + if (searchDto.fileTypes.length > 0) { + // if decision is not joined yet -> join it. The join of decision happens in compileApplicationDecisionSearchQuery + if ( + searchDto.dateDecidedFrom === undefined && + searchDto.dateDecidedTo === undefined + ) { + query = this.joinApplicationDecision(query); + } + + query = query.leftJoin( + ApplicationDecisionComponent, + 'decisionComponent', + 'decisionComponent.application_decision_uuid = decision.uuid', + ); + + query = query.andWhere( + new Brackets((qb) => + qb + .where('appSearch.application_type_code IN (:...typeCodes)', { + typeCodes: searchDto.fileTypes, + }) + .orWhere( + 'decisionComponent.application_decision_component_type_code IN (:...typeCodes)', + { + typeCodes: searchDto.fileTypes, + }, + ), + ), + ); + } + + return query; + } +} diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts new file mode 100644 index 0000000000..bac002d236 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts @@ -0,0 +1,120 @@ +import { + DataSource, + JoinColumn, + ManyToOne, + PrimaryColumn, + ViewColumn, + ViewEntity, +} from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntentDecision } from '../../../../alcs/notice-of-intent-decision/notice-of-intent-decision.entity'; +import { NoticeOfIntentSubmissionToSubmissionStatus } from '../../../../alcs/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-status.entity'; +import { NoticeOfIntentType } from '../../../../alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; +import { NoticeOfIntent } from '../../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { NoticeOfIntentSubmission } from '../../../notice-of-intent-submission/notice-of-intent-submission.entity'; +import { LinkedStatusType } from '../public-search.dto'; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .createQueryBuilder() + .select('noi_sub.uuid', 'uuid') + .addSelect('noi_sub.file_number', 'file_number') + .addSelect('noi_sub.applicant', 'applicant') + .addSelect('noi_sub.local_government_uuid', 'local_government_uuid') + .addSelect('localGovernment.name', 'local_government_name') + .addSelect('noi_sub.type_code', 'notice_of_intent_type_code') + .addSelect('noi.date_submitted_to_alc', 'date_submitted_to_alc') + .addSelect('noi.decision_date', 'decision_date') + .addSelect('noi.uuid', 'notice_of_intent_uuid') + .addSelect('noi.region_code', 'notice_of_intent_region_code') + .addSelect( + 'GREATEST(status_link.effective_date, decision_date.date)', + 'last_update', + ) + .addSelect( + 'alcs.get_current_status_for_notice_of_intent_submission_by_uuid(noi_sub.uuid)', + 'status', + ) + .from(NoticeOfIntentSubmission, 'noi_sub') + .innerJoin(NoticeOfIntent, 'noi', 'noi.file_number = noi_sub.file_number') + .innerJoinAndSelect( + NoticeOfIntentType, + 'noticeOfIntentType', + 'noi_sub.type_code = noticeOfIntentType.code', + ) + .leftJoin( + LocalGovernment, + 'localGovernment', + 'noi_sub.local_government_uuid = localGovernment.uuid', + ) + .leftJoin( + (qb) => + qb + .from(NoticeOfIntentSubmissionToSubmissionStatus, 'status_link') + .select('MAX("effective_date")', 'effective_date') + .addSelect('submission_uuid', 'submission_uuid') + .groupBy('submission_uuid'), + 'status_link', + 'status_link."submission_uuid" = noi_sub.uuid', + ) + .leftJoin( + (qb) => + qb + .from(NoticeOfIntentDecision, 'decision_date') + .select('MAX("date")', 'date') + .addSelect('notice_of_intent_uuid', 'notice_of_intent_uuid') + .groupBy('notice_of_intent_uuid'), + 'decision_date', + 'decision_date."notice_of_intent_uuid" = noi.uuid', + ) + .where('noi_sub.is_draft = FALSE') + .andWhere('noi.date_received_all_items IS NOT NULL') + .andWhere( + "alcs.get_current_status_for_notice_of_intent_submission_by_uuid(noi_sub.uuid)->>'status_type_code' != 'CNCL'", + ), +}) +export class PublicNoticeOfIntentSubmissionSearchView { + @ViewColumn() + @PrimaryColumn() + uuid: string; + + @ViewColumn() + noticeOfIntentUuid: string; + + @ViewColumn() + lastUpdate: Date; + + @ViewColumn() + noticeOfIntentRegionCode?: string; + + @ViewColumn() + fileNumber: string; + + @ViewColumn() + applicant?: string; + + @ViewColumn() + localGovernmentUuid?: string; + + @ViewColumn() + localGovernmentName?: string; + + @ViewColumn() + noticeOfIntentTypeCode: string; + + @ViewColumn() + status: LinkedStatusType; + + @ViewColumn() + dateSubmittedToAlc: Date | null; + + @ViewColumn() + decisionDate: Date | null; + + @ManyToOne(() => NoticeOfIntentType, { + nullable: false, + }) + @JoinColumn({ name: 'notice_of_intent_type_code' }) + noticeOfIntentType: NoticeOfIntentType; +} diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts new file mode 100644 index 0000000000..565fb98c22 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts @@ -0,0 +1,108 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { SearchRequestDto } from '../public-search.dto'; +import { PublicNoticeOfIntentSearchService } from './public-notice-of-intent-search.service'; +import { PublicNoticeOfIntentSubmissionSearchView } from './public-notice-of-intent-search-view.entity'; + +describe('PublicNoticeOfIntentSearchService', () => { + let service: PublicNoticeOfIntentSearchService; + let mockNoticeOfIntentSubmissionSearchViewRepository: DeepMocked< + Repository + >; + let mockLocalGovernmentRepository: DeepMocked>; + + const mockSearchDto: SearchRequestDto = { + fileNumber: '123', + portalStatusCode: 'A', + governmentName: 'B', + regionCode: 'C', + name: 'D', + pid: 'E', + civicAddress: 'F', + dateDecidedFrom: new Date('2020-11-10').getTime(), + dateDecidedTo: new Date('2021-11-10').getTime(), + fileTypes: ['type1', 'type2'], + page: 1, + pageSize: 10, + sortField: 'ownerName', + sortDirection: 'ASC', + }; + + let mockQuery: any = {}; + + beforeEach(async () => { + mockNoticeOfIntentSubmissionSearchViewRepository = createMock(); + mockLocalGovernmentRepository = createMock(); + + mockQuery = { + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + innerJoinAndMapOne: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PublicNoticeOfIntentSearchService, + { + provide: getRepositoryToken(PublicNoticeOfIntentSubmissionSearchView), + useValue: mockNoticeOfIntentSubmissionSearchViewRepository, + }, + { + provide: getRepositoryToken(LocalGovernment), + useValue: mockLocalGovernmentRepository, + }, + ], + }).compile(); + + service = module.get( + PublicNoticeOfIntentSearchService, + ); + + mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( + new LocalGovernment(), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should successfully build a query using all search parameters defined', async () => { + mockNoticeOfIntentSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery as any, + ); + + const result = await service.searchNoticeOfIntents(mockSearchDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect( + mockNoticeOfIntentSubmissionSearchViewRepository.createQueryBuilder, + ).toBeCalledTimes(1); + expect(mockQuery.andWhere).toBeCalledTimes(7); + expect(mockQuery.where).toBeCalledTimes(1); + }); + + it('should call compileNoticeOfIntentSearchQuery method correctly', async () => { + const compileApplicationSearchQuerySpy = jest + .spyOn(service as any, 'compileNoticeOfIntentSearchQuery') + .mockResolvedValue(mockQuery); + + const result = await service.searchNoticeOfIntents(mockSearchDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect(compileApplicationSearchQuerySpy).toBeCalledWith(mockSearchDto); + expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); + expect(mockQuery.offset).toHaveBeenCalledTimes(1); + expect(mockQuery.limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts new file mode 100644 index 0000000000..c801595a5d --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts @@ -0,0 +1,254 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, Repository } from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntentDecision } from '../../../../alcs/notice-of-intent-decision/notice-of-intent-decision.entity'; +import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { NoticeOfIntentOwner } from '../../../notice-of-intent-submission/notice-of-intent-owner/notice-of-intent-owner.entity'; +import { NoticeOfIntentParcel } from '../../../notice-of-intent-submission/notice-of-intent-parcel/notice-of-intent-parcel.entity'; +import { + AdvancedSearchResultDto, + SearchRequestDto, +} from '../public-search.dto'; +import { PublicNoticeOfIntentSubmissionSearchView } from './public-notice-of-intent-search-view.entity'; + +@Injectable() +export class PublicNoticeOfIntentSearchService { + constructor( + @InjectRepository(PublicNoticeOfIntentSubmissionSearchView) + private noiSearchRepository: Repository, + @InjectRepository(LocalGovernment) + private governmentRepository: Repository, + ) {} + + async searchNoticeOfIntents( + searchDto: SearchRequestDto, + ): Promise< + AdvancedSearchResultDto + > { + let query = await this.compileNoticeOfIntentSearchQuery(searchDto); + + query = this.compileGroupBySearchQuery(query); + + const sortQuery = this.compileSortQuery(searchDto); + + query = query + .orderBy(sortQuery, searchDto.sortDirection) + .offset((searchDto.page - 1) * searchDto.pageSize) + .limit(searchDto.pageSize); + + const result = await query.getManyAndCount(); + + return { + data: result[0], + total: result[1], + }; + } + + private compileSortQuery(searchDto: SearchRequestDto) { + switch (searchDto.sortField) { + case 'fileId': + return '"noiSearch"."file_number"'; + + case 'ownerName': + return '"noiSearch"."applicant"'; + + case 'type': + return '"noiSearch"."notice_of_intent_type_code"'; + + case 'government': + return '"noiSearch"."local_government_name"'; + + case 'portalStatus': + return `"noiSearch"."status" ->> 'label' `; + + default: + case 'dateSubmitted': + return '"noiSearch"."date_submitted_to_alc"'; + } + } + + private compileGroupBySearchQuery(query) { + query = query + .innerJoinAndMapOne( + 'noiSearch.noticeOfIntentType', + 'noiSearch.noticeOfIntentType', + 'noticeOfIntentType', + ) + .groupBy( + ` + "noiSearch"."uuid" + , "noiSearch"."notice_of_intent_uuid" + , "noiSearch"."notice_of_intent_region_code" + , "noiSearch"."file_number" + , "noiSearch"."applicant" + , "noiSearch"."local_government_uuid" + , "noiSearch"."local_government_name" + , "noiSearch"."notice_of_intent_type_code" + , "noiSearch"."status" + , "noiSearch"."date_submitted_to_alc" + , "noiSearch"."decision_date" + , "noticeOfIntentType"."audit_deleted_date_at" + , "noticeOfIntentType"."audit_created_at" + , "noticeOfIntentType"."audit_updated_by" + , "noticeOfIntentType"."audit_updated_at" + , "noticeOfIntentType"."audit_created_by" + , "noticeOfIntentType"."short_label" + , "noticeOfIntentType"."label" + , "noticeOfIntentType"."code" + , "noticeOfIntentType"."html_description" + , "noticeOfIntentType"."portal_label" + , "noiSearch"."is_draft" + `, + ); + return query; + } + + private async compileNoticeOfIntentSearchQuery(searchDto: SearchRequestDto) { + let query = this.noiSearchRepository + .createQueryBuilder('noiSearch') + .where('noiSearch.is_draft = false'); + + if (searchDto.fileNumber) { + query = query + .andWhere('noiSearch.file_number = :fileNumber') + .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + } + + if (searchDto.portalStatusCode) { + query = query.andWhere( + "alcs.get_current_status_for_notice_of_intent_submission_by_uuid(noiSearch.uuid) ->> 'status_type_code' = :status", + { + status: searchDto.portalStatusCode, + }, + ); + } + + if (searchDto.governmentName) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); + + query = query.andWhere( + 'noiSearch.local_government_uuid = :local_government_uuid', + { + local_government_uuid: government.uuid, + }, + ); + } + + if (searchDto.regionCode) { + query = query.andWhere( + 'noiSearch.notice_of_intent_region_code = :noi_region_code', + { + noi_region_code: searchDto.regionCode, + }, + ); + } + + query = this.compileSearchByNameQuery(searchDto, query); + query = this.compileParcelSearchQuery(searchDto, query); + query = this.compileDecisionSearchQuery(searchDto, query); + + return query; + } + + private compileDecisionSearchQuery(searchDto: SearchRequestDto, query) { + if ( + searchDto.dateDecidedTo !== undefined || + searchDto.dateDecidedFrom !== undefined + ) { + query = this.joinDecision(query); + + // if (searchDto.resolutionNumber !== undefined) { + // query = query.andWhere( + // 'decision.resolution_number = :resolution_number', + // { + // resolution_number: searchDto.resolutionNumber, + // }, + // ); + // } + // + // if (searchDto.resolutionYear !== undefined) { + // query = query.andWhere('decision.resolution_year = :resolution_year', { + // resolution_year: searchDto.resolutionYear, + // }); + // } + } + return query; + } + + private joinDecision(query: any) { + query = query.leftJoin( + NoticeOfIntentDecision, + 'decision', + 'decision.notice_of_intent_uuid = "noiSearch"."notice_of_intent_uuid"', + ); + return query; + } + + private compileParcelSearchQuery(searchDto: SearchRequestDto, query) { + if (searchDto.pid || searchDto.civicAddress) { + query = query.leftJoin( + NoticeOfIntentParcel, + 'parcel', + 'parcel.notice_of_intent_submission_uuid = noiSearch.uuid', + ); + } + + if (searchDto.pid) { + query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); + } + + if (searchDto.civicAddress) { + query = query.andWhere('parcel.civic_address like :civic_address', { + civic_address: `%${searchDto.civicAddress}%`, + }); + } + return query; + } + + private compileSearchByNameQuery(searchDto: SearchRequestDto, query) { + if (searchDto.name) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + + query = query + .leftJoin( + NoticeOfIntentOwner, + 'notice_of_intent_owner', + 'notice_of_intent_owner.notice_of_intent_submission_uuid = noiSearch.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(notice_of_intent_owner.first_name || ' ' || notice_of_intent_owner.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.first_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.last_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ); + } + return query; + } +} diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts new file mode 100644 index 0000000000..f553def390 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts @@ -0,0 +1,89 @@ +import { + DataSource, + JoinColumn, + ManyToOne, + PrimaryColumn, + ViewColumn, + ViewEntity, +} from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { NotificationType } from '../../../../alcs/notification/notification-type/notification-type.entity'; +import { Notification } from '../../../../alcs/notification/notification.entity'; +import { NotificationSubmission } from '../../../notification-submission/notification-submission.entity'; +import { LinkedStatusType } from '../public-search.dto'; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .createQueryBuilder() + .select('noti_sub.uuid', 'uuid') + .addSelect('noti_sub.file_number', 'file_number') + .addSelect('noti_sub.applicant', 'applicant') + .addSelect('noti_sub.local_government_uuid', 'local_government_uuid') + .addSelect('localGovernment.name', 'local_government_name') + .addSelect('noti.type_code', 'notification_type_code') + .addSelect('noti.date_submitted_to_alc', 'date_submitted_to_alc') + .addSelect('noti.uuid', 'notification_uuid') + .addSelect('noti.region_code', 'notification_region_code') + .addSelect( + 'alcs.get_current_status_for_notification_submission_by_uuid(noti_sub.uuid)', + 'status', + ) + .from(NotificationSubmission, 'noti_sub') + .innerJoin( + Notification, + 'noti', + 'noti.file_number = noti_sub.file_number', + ) + .innerJoinAndSelect( + NotificationType, + 'notificationType', + 'noti_sub.type_code = notificationType.code', + ) + .leftJoin( + LocalGovernment, + 'localGovernment', + 'noti.local_government_uuid = localGovernment.uuid', + ) + .where( + "alcs.get_current_status_for_notification_submission_by_uuid(noti_sub.uuid)->>'status_type_code' = 'ALCR'", + ), +}) +export class PublicNotificationSubmissionSearchView { + @ViewColumn() + @PrimaryColumn() + uuid: string; + + @ViewColumn() + notificationUuid: string; + + @ViewColumn() + notificationRegionCode?: string; + + @ViewColumn() + fileNumber: string; + + @ViewColumn() + applicant?: string; + + @ViewColumn() + localGovernmentUuid?: string; + + @ViewColumn() + localGovernmentName?: string; + + @ViewColumn() + notificationTypeCode: string; + + @ViewColumn() + status: LinkedStatusType; + + @ViewColumn() + dateSubmittedToAlc: Date | null; + + @ManyToOne(() => NotificationType, { + nullable: false, + }) + @JoinColumn({ name: 'notification_type_code' }) + notificationType: NotificationType; +} diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts new file mode 100644 index 0000000000..9b4787db8d --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts @@ -0,0 +1,107 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { SearchRequestDto } from '../public-search.dto'; +import { PublicNotificationSearchService } from './public-notification-search.service'; +import { PublicNotificationSubmissionSearchView } from './public-notification-search-view.entity'; + +describe('PublicNotificationSearchService', () => { + let service: PublicNotificationSearchService; + let mockNotificationSubmissionSearchViewRepository: DeepMocked< + Repository + >; + let mockLocalGovernmentRepository: DeepMocked>; + + const mockSearchDto: SearchRequestDto = { + fileNumber: '123', + portalStatusCode: 'A', + governmentName: 'B', + regionCode: 'C', + name: 'D', + pid: 'E', + civicAddress: 'F', + dateDecidedFrom: new Date('2020-11-10').getTime(), + dateDecidedTo: new Date('2021-11-10').getTime(), + fileTypes: ['type1', 'type2'], + page: 1, + pageSize: 10, + sortField: 'ownerName', + sortDirection: 'ASC', + }; + + let mockQuery: any = {}; + + beforeEach(async () => { + mockNotificationSubmissionSearchViewRepository = createMock(); + mockLocalGovernmentRepository = createMock(); + + mockQuery = { + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + innerJoinAndMapOne: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PublicNotificationSearchService, + { + provide: getRepositoryToken(PublicNotificationSubmissionSearchView), + useValue: mockNotificationSubmissionSearchViewRepository, + }, + { + provide: getRepositoryToken(LocalGovernment), + useValue: mockLocalGovernmentRepository, + }, + ], + }).compile(); + + service = module.get( + PublicNotificationSearchService, + ); + + mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( + new LocalGovernment(), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should successfully build a query using all search parameters defined', async () => { + mockNotificationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery as any, + ); + + const result = await service.search(mockSearchDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect( + mockNotificationSubmissionSearchViewRepository.createQueryBuilder, + ).toBeCalledTimes(1); + expect(mockQuery.andWhere).toBeCalledTimes(7); + }); + + it('should call compileNotificationSearchQuery method correctly', async () => { + const compileSearchQuerySpy = jest + .spyOn(service as any, 'compileNotificationSearchQuery') + .mockResolvedValue(mockQuery); + + const result = await service.search(mockSearchDto); + + expect(result).toEqual({ data: [], total: 0 }); + expect(compileSearchQuerySpy).toBeCalledWith(mockSearchDto); + expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); + expect(mockQuery.offset).toHaveBeenCalledTimes(1); + expect(mockQuery.limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts new file mode 100644 index 0000000000..c06df8e22d --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts @@ -0,0 +1,215 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, Repository } from 'typeorm'; +import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { NotificationParcel } from '../../../notification-submission/notification-parcel/notification-parcel.entity'; +import { NotificationTransferee } from '../../../notification-submission/notification-transferee/notification-transferee.entity'; +import { + AdvancedSearchResultDto, + SearchRequestDto, +} from '../public-search.dto'; +import { PublicNotificationSubmissionSearchView } from './public-notification-search-view.entity'; + +@Injectable() +export class PublicNotificationSearchService { + constructor( + @InjectRepository(PublicNotificationSubmissionSearchView) + private notificationSearchViewRepo: Repository, + @InjectRepository(LocalGovernment) + private governmentRepository: Repository, + ) {} + + async search( + searchDto: SearchRequestDto, + ): Promise< + AdvancedSearchResultDto + > { + let query = await this.compileNotificationSearchQuery(searchDto); + + query = this.compileGroupBySearchQuery(query); + + const sortQuery = this.compileSortQuery(searchDto); + + query = query + .orderBy(sortQuery, searchDto.sortDirection) + .offset((searchDto.page - 1) * searchDto.pageSize) + .limit(searchDto.pageSize); + + const result = await query.getManyAndCount(); + + return { + data: result[0], + total: result[1], + }; + } + + private compileSortQuery(searchDto: SearchRequestDto) { + switch (searchDto.sortField) { + case 'fileId': + return '"notificationSearch"."file_number"'; + + case 'ownerName': + return '"notificationSearch"."applicant"'; + + case 'type': + return '"notificationSearch"."notification_type_code"'; + + case 'government': + return '"notificationSearch"."local_government_name"'; + + case 'portalStatus': + return `"notificationSearch"."status" ->> 'label' `; + + default: + case 'dateSubmitted': + return '"notificationSearch"."date_submitted_to_alc"'; + } + } + + private compileGroupBySearchQuery(query) { + query = query + .innerJoinAndMapOne( + 'notificationSearch.notificationType', + 'notificationSearch.notificationType', + 'notificationType', + ) + .groupBy( + ` + "notificationSearch"."uuid" + , "notificationSearch"."notification_uuid" + , "notificationSearch"."notification_region_code" + , "notificationSearch"."file_number" + , "notificationSearch"."applicant" + , "notificationSearch"."local_government_uuid" + , "notificationSearch"."local_government_name" + , "notificationSearch"."notification_type_code" + , "notificationSearch"."status" + , "notificationSearch"."date_submitted_to_alc" + , "notificationType"."audit_deleted_date_at" + , "notificationType"."audit_created_at" + , "notificationType"."audit_updated_by" + , "notificationType"."audit_updated_at" + , "notificationType"."audit_created_by" + , "notificationType"."short_label" + , "notificationType"."label" + , "notificationType"."code" + , "notificationType"."html_description" + , "notificationType"."portal_label" + `, + ); + return query; + } + + private async compileNotificationSearchQuery(searchDto: SearchRequestDto) { + let query = + this.notificationSearchViewRepo.createQueryBuilder('notificationSearch'); + + if (searchDto.fileNumber) { + query = query + .andWhere('notificationSearch.file_number = :fileNumber') + .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + } + + if (searchDto.portalStatusCode) { + query = query.andWhere( + "alcs.get_current_status_for_notification_submission_by_uuid(notificationSearch.uuid) ->> 'status_type_code' = :status", + { + status: searchDto.portalStatusCode, + }, + ); + } + + if (searchDto.governmentName) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); + + query = query.andWhere( + 'notificationSearch.local_government_uuid = :local_government_uuid', + { + local_government_uuid: government.uuid, + }, + ); + } + + if (searchDto.regionCode) { + query = query.andWhere( + 'notificationSearch.notification_region_code = :region_code', + { + region_code: searchDto.regionCode, + }, + ); + } + + query = this.compileSearchByNameQuery(searchDto, query); + query = this.compileParcelSearchQuery(searchDto, query); + + return query; + } + + private compileParcelSearchQuery(searchDto: SearchRequestDto, query) { + if (searchDto.pid || searchDto.civicAddress) { + query = query.leftJoin( + NotificationParcel, + 'parcel', + 'parcel.notification_submission_uuid = notificationSearch.uuid', + ); + } + + if (searchDto.pid) { + query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); + } + + if (searchDto.civicAddress) { + query = query.andWhere('parcel.civic_address like :civic_address', { + civic_address: `%${searchDto.civicAddress}%`, + }); + } + return query; + } + + private compileSearchByNameQuery(searchDto: SearchRequestDto, query) { + if (searchDto.name) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + + query = query + .leftJoin( + NotificationTransferee, + 'notification_transferee', + 'notification_transferee.notification_submission_uuid = notificationSearch.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(notification_transferee.first_name || ' ' || notification_transferee.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.first_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.last_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ); + } + return query; + } +} diff --git a/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts b/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts new file mode 100644 index 0000000000..5c00d6a9d4 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts @@ -0,0 +1,243 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; +import { Application } from '../../../alcs/application/application.entity'; +import { NoticeOfIntent } from '../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { Notification } from '../../../alcs/notification/notification.entity'; +import { PublicApplicationSearchService } from './application/public-application-search.service'; +import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; +import { PublicNotificationSearchService } from './notification/public-notification-search.service'; +import { PublicSearchController } from './public-search.controller'; +import { SearchRequestDto } from './public-search.dto'; +import { PublicSearchService } from './public-search.service'; + +describe('PublicSearchController', () => { + let controller: PublicSearchController; + let mockSearchService: DeepMocked; + let mockNoticeOfIntentAdvancedSearchService: DeepMocked; + let mockApplicationAdvancedSearchService: DeepMocked; + let mockNotificationAdvancedSearchService: DeepMocked; + + beforeEach(async () => { + mockSearchService = createMock(); + mockNoticeOfIntentAdvancedSearchService = createMock(); + mockApplicationAdvancedSearchService = createMock(); + mockNotificationAdvancedSearchService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + { + provide: PublicSearchService, + useValue: mockSearchService, + }, + { + provide: PublicNoticeOfIntentSearchService, + useValue: mockNoticeOfIntentAdvancedSearchService, + }, + { + provide: PublicApplicationSearchService, + useValue: mockApplicationAdvancedSearchService, + }, + { + provide: PublicNotificationSearchService, + useValue: mockNotificationAdvancedSearchService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + controllers: [PublicSearchController], + }).compile(); + + controller = module.get(PublicSearchController); + + mockSearchService.getApplication.mockResolvedValue(new Application()); + mockSearchService.getNoi.mockResolvedValue(new NoticeOfIntent()); + mockSearchService.getNotification.mockResolvedValue(new Notification()); + + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents.mockResolvedValue( + { + data: [], + total: 0, + }, + ); + + mockApplicationAdvancedSearchService.searchApplications.mockResolvedValue({ + data: [], + total: 0, + }); + + mockNotificationAdvancedSearchService.search.mockResolvedValue({ + data: [], + total: 0, + }); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should call search to retrieve Applications, NOIs, PlanningReviews, Covenants, Notifications', async () => { + const mockSearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + isIncludeOtherParcels: false, + name: 'test', + fileTypes: [], + }; + + const result = await controller.search( + mockSearchRequestDto as SearchRequestDto, + ); + + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledTimes(1); + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.applications).toBeDefined(); + expect(result.totalApplications).toBe(0); + + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledTimes(1); + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.noticeOfIntents).toBeDefined(); + expect(result.totalNoticeOfIntents).toBe(0); + }); + + it('should call applications advanced search to retrieve Applications', async () => { + const mockSearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + isIncludeOtherParcels: false, + fileTypes: [], + }; + + const result = await controller.advancedSearchApplications( + mockSearchRequestDto as SearchRequestDto, + ); + + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledTimes(1); + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.data).toBeDefined(); + expect(result.total).toBe(0); + }); + + it('should call NOI advanced search to retrieve NOIs', async () => { + const mockSearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + fileTypes: [], + }; + + const result = await controller.advancedSearchNoticeOfIntents( + mockSearchRequestDto as SearchRequestDto, + ); + + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledTimes(1); + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.data).toBeDefined(); + expect(result.total).toBe(0); + }); + + it('should call search to retrieve Applications only when application file type selected', async () => { + const mockSearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + fileTypes: ['NFUP'], + }; + + const result = await controller.search( + mockSearchRequestDto as SearchRequestDto, + ); + + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledTimes(1); + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.applications).toBeDefined(); + expect(result.totalApplications).toBe(0); + }); + + it('should call search to retrieve NOIs only when NOI file type selected', async () => { + const mockSearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + fileTypes: ['NOI'], + }; + + const result = await controller.search( + mockSearchRequestDto as SearchRequestDto, + ); + + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledTimes(1); + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.noticeOfIntents).toBeDefined(); + expect(result.totalNoticeOfIntents).toBe(0); + }); + + it('should NOT call NOI search to retrieve if file type app specified', async () => { + const mockSearchRequestDto: SearchRequestDto = { + pageSize: 1, + page: 1, + sortField: '1', + sortDirection: 'ASC', + fileTypes: ['NFUP'], + }; + + const result = await controller.search(mockSearchRequestDto); + + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledTimes(1); + expect( + mockApplicationAdvancedSearchService.searchApplications, + ).toBeCalledWith(mockSearchRequestDto); + expect(result.applications).toBeDefined(); + expect(result.totalApplications).toBe(0); + + expect( + mockNoticeOfIntentAdvancedSearchService.searchNoticeOfIntents, + ).toBeCalledTimes(0); + expect(result.noticeOfIntents).toBeDefined(); + expect(result.totalNoticeOfIntents).toBe(0); + }); +}); diff --git a/services/apps/alcs/src/portal/public/search/public-search.controller.ts b/services/apps/alcs/src/portal/public/search/public-search.controller.ts new file mode 100644 index 0000000000..f16d98dd5a --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.controller.ts @@ -0,0 +1,271 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { Body, Controller, Post } from '@nestjs/common'; +import { ApplicationType } from '../../../alcs/code/application-code/application-type/application-type.entity'; +import { isStringSetAndNotEmpty } from '../../../utils/string-helper'; +import { ApplicationTypeDto } from '../../code/code.dto'; +import { APPLICATION_SUBMISSION_TYPES } from '../../pdf-generation/generate-submission-document.service'; +import { PublicApplicationSearchService } from './application/public-application-search.service'; +import { PublicApplicationSubmissionSearchView } from './application/public-application-search-view.entity'; +import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; +import { PublicNoticeOfIntentSubmissionSearchView } from './notice-of-intent/public-notice-of-intent-search-view.entity'; +import { PublicNotificationSearchService } from './notification/public-notification-search.service'; +import { PublicNotificationSubmissionSearchView } from './notification/public-notification-search-view.entity'; +import { + AdvancedSearchResponseDto, + AdvancedSearchResultDto, + ApplicationSearchResultDto, + NoticeOfIntentSearchResultDto, + NotificationSearchResultDto, + SearchRequestDto, +} from './public-search.dto'; +import { PublicSearchService } from './public-search.service'; + +@Controller('search') +export class PublicSearchController { + constructor( + private searchService: PublicSearchService, + @InjectMapper() private mapper: Mapper, + private noticeOfIntentSearchService: PublicNoticeOfIntentSearchService, + private applicationSearchService: PublicApplicationSearchService, + private notificationSearchService: PublicNotificationSearchService, + ) {} + + @Post('/') + async search(@Body() searchDto: SearchRequestDto) { + const { searchApplications, searchNoi, searchNotifications } = + this.getEntitiesTypeToSearch(searchDto); + + let applicationSearchResult: AdvancedSearchResultDto< + PublicApplicationSubmissionSearchView[] + > | null = null; + if (searchApplications) { + applicationSearchResult = + await this.applicationSearchService.searchApplications(searchDto); + } + + let noticeOfIntentResults: AdvancedSearchResultDto< + PublicNoticeOfIntentSubmissionSearchView[] + > | null = null; + if (searchNoi) { + noticeOfIntentResults = + await this.noticeOfIntentSearchService.searchNoticeOfIntents(searchDto); + } + + let notifications: AdvancedSearchResultDto< + PublicNotificationSubmissionSearchView[] + > | null = null; + if (searchNotifications) { + notifications = await this.notificationSearchService.search(searchDto); + } + + return this.mapAdvancedSearchResults( + applicationSearchResult, + noticeOfIntentResults, + notifications, + ); + } + + @Post('/application') + async advancedSearchApplications( + @Body() searchDto: SearchRequestDto, + ): Promise> { + const applications = await this.applicationSearchService.searchApplications( + searchDto, + ); + + const mappedSearchResult = this.mapAdvancedSearchResults( + applications, + null, + null, + ); + + return { + total: mappedSearchResult.totalApplications, + data: mappedSearchResult.applications, + }; + } + + @Post('/notice-of-intent') + async advancedSearchNoticeOfIntents( + @Body() searchDto: SearchRequestDto, + ): Promise> { + const noticeOfIntents = + await this.noticeOfIntentSearchService.searchNoticeOfIntents(searchDto); + + const mappedSearchResult = this.mapAdvancedSearchResults( + null, + noticeOfIntents, + null, + ); + + return { + total: mappedSearchResult.totalNoticeOfIntents, + data: mappedSearchResult.noticeOfIntents, + }; + } + + @Post('/notifications') + async advancedSearchNotifications( + @Body() searchDto: SearchRequestDto, + ): Promise> { + const notifications = await this.notificationSearchService.search( + searchDto, + ); + + const mappedSearchResult = this.mapAdvancedSearchResults( + null, + null, + notifications, + ); + + return { + total: mappedSearchResult.totalNotifications, + data: mappedSearchResult.notifications, + }; + } + + private getEntitiesTypeToSearch(searchDto: SearchRequestDto) { + let searchApplications = true; + let noiTypeSpecified = false; + let notificationTypeSpecified = false; + + if (searchDto.fileTypes.length > 0) { + searchApplications = + searchDto.fileTypes.filter((searchType) => + Object.values(APPLICATION_SUBMISSION_TYPES).includes( + APPLICATION_SUBMISSION_TYPES[ + searchType as keyof typeof APPLICATION_SUBMISSION_TYPES + ], + ), + ).length > 0; + + noiTypeSpecified = searchDto.fileTypes.includes('NOI'); + notificationTypeSpecified = searchDto.fileTypes.includes('SRW'); + } + + const searchNoi = searchDto.fileTypes.length > 0 ? noiTypeSpecified : true; + + const searchNotifications = + (searchDto.fileTypes.length > 0 ? notificationTypeSpecified : true) && + !searchDto.dateDecidedFrom && + !searchDto.dateDecidedTo && + !isStringSetAndNotEmpty(searchDto.civicAddress); + + return { + searchApplications, + searchNoi, + searchNotifications, + }; + } + + private mapAdvancedSearchResults( + applications: AdvancedSearchResultDto< + PublicApplicationSubmissionSearchView[] + > | null, + noticeOfIntents: AdvancedSearchResultDto< + PublicNoticeOfIntentSubmissionSearchView[] + > | null, + notifications: AdvancedSearchResultDto< + PublicNotificationSubmissionSearchView[] + > | null, + ) { + const response = new AdvancedSearchResponseDto(); + + const mappedApplications: ApplicationSearchResultDto[] = []; + if (applications && applications.data.length > 0) { + mappedApplications.push( + ...applications.data.map((app) => + this.mapApplicationToAdvancedSearchResult(app), + ), + ); + } + + const mappedNoticeOfIntents: NoticeOfIntentSearchResultDto[] = []; + if (noticeOfIntents && noticeOfIntents.data.length > 0) { + mappedNoticeOfIntents.push( + ...noticeOfIntents.data.map((noi) => + this.mapNoticeOfIntentToAdvancedSearchResult(noi), + ), + ); + } + + const mappedNotifications: NotificationSearchResultDto[] = []; + if (notifications && notifications.data && notifications.data.length > 0) { + mappedNotifications.push( + ...notifications.data.map((notification) => + this.mapNotificationToAdvancedSearchResult(notification), + ), + ); + } + + response.applications = mappedApplications; + response.totalApplications = applications?.total ?? 0; + response.noticeOfIntents = mappedNoticeOfIntents; + response.totalNoticeOfIntents = noticeOfIntents?.total ?? 0; + response.notifications = mappedNotifications; + response.totalNotifications = notifications?.total ?? 0; + + return response; + } + + private mapApplicationToAdvancedSearchResult( + application: PublicApplicationSubmissionSearchView, + ): ApplicationSearchResultDto { + return { + referenceId: application.fileNumber, + fileNumber: application.fileNumber, + dateSubmitted: application.dateSubmittedToAlc?.getTime(), + type: this.mapper.map( + application.applicationType, + ApplicationType, + ApplicationTypeDto, + ), + lastUpdate: application.lastUpdate?.getTime(), + localGovernmentName: application.localGovernmentName, + ownerName: application.applicant, + class: 'APP', + status: application.status.status_type_code, + }; + } + + private mapNoticeOfIntentToAdvancedSearchResult( + noi: PublicNoticeOfIntentSubmissionSearchView, + ): NoticeOfIntentSearchResultDto { + return { + referenceId: noi.fileNumber, + fileNumber: noi.fileNumber, + lastUpdate: noi.lastUpdate?.getTime(), + dateSubmitted: noi.dateSubmittedToAlc?.getTime(), + type: this.mapper.map( + noi.noticeOfIntentType, + ApplicationType, + ApplicationTypeDto, + ), + localGovernmentName: noi.localGovernmentName, + ownerName: noi.applicant, + class: 'NOI', + status: noi.status.status_type_code, + }; + } + + private mapNotificationToAdvancedSearchResult( + notification: PublicNotificationSubmissionSearchView, + ): NoticeOfIntentSearchResultDto { + return { + referenceId: notification.fileNumber, + fileNumber: notification.fileNumber, + lastUpdate: notification.status.effective_date.getTime(), + dateSubmitted: notification.dateSubmittedToAlc?.getTime(), + type: this.mapper.map( + notification.notificationType, + ApplicationType, + ApplicationTypeDto, + ), + localGovernmentName: notification.localGovernmentName, + ownerName: notification.applicant, + class: 'NOTI', + status: notification.status.status_type_code, + }; + } +} diff --git a/services/apps/alcs/src/portal/public/search/public-search.dto.ts b/services/apps/alcs/src/portal/public/search/public-search.dto.ts new file mode 100644 index 0000000000..3b8490385a --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.dto.ts @@ -0,0 +1,129 @@ +import { + IsArray, + IsNumber, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; +import { ApplicationTypeDto } from '../../code/code.dto'; + +export type SearchEntityClass = 'APP' | 'NOI' | 'NOTI'; + +export class ApplicationSearchResultDto { + type: ApplicationTypeDto; + referenceId: string; + ownerName?: string; + localGovernmentName?: string; + fileNumber: string; + boardCode?: string; + status: string; + dateSubmitted?: number; + lastUpdate?: number; + class: SearchEntityClass; +} + +export class NoticeOfIntentSearchResultDto { + type: ApplicationTypeDto; + referenceId: string; + ownerName?: string; + localGovernmentName?: string; + fileNumber: string; + boardCode?: string; + status: string; + dateSubmitted?: number; + lastUpdate?: number; + class: SearchEntityClass; +} + +export class NotificationSearchResultDto { + type: ApplicationTypeDto; + referenceId: string; + ownerName?: string; + localGovernmentName?: string; + fileNumber: string; + boardCode?: string; + status: string; + dateSubmitted?: number; + lastUpdate?: number; + class: SearchEntityClass; +} + +export class AdvancedSearchResponseDto { + applications: ApplicationSearchResultDto[]; + noticeOfIntents: NoticeOfIntentSearchResultDto[]; + notifications: NotificationSearchResultDto[]; + totalApplications: number; + totalNoticeOfIntents: number; + totalNotifications: number; +} + +export class AdvancedSearchResultDto { + data: T; + total: number; +} + +export class PagingRequestDto { + @IsNumber() + page: number; + + @IsNumber() + pageSize: number; + + @IsString() + sortField: string; + + @IsString() + sortDirection: 'ASC' | 'DESC'; +} + +export class SearchRequestDto extends PagingRequestDto { + @IsString() + @IsOptional() + fileNumber?: string; + + @IsString() + @IsOptional() + @MinLength(3) + name?: string; + + @IsString() + @IsOptional() + @MinLength(9) + pid?: string; + + @IsString() + @IsOptional() + @MinLength(3) + civicAddress?: string; + + @IsString() + @IsOptional() + portalStatusCode?: string; + + @IsString() + @IsOptional() + governmentName?: string; + + @IsString() + @IsOptional() + regionCode?: string; + + @IsNumber() + @IsOptional() + dateDecidedFrom?: number; + + @IsNumber() + @IsOptional() + dateDecidedTo?: number; + + @IsArray() + fileTypes: string[]; +} + +// typeorm does not transform property names for the status +export class LinkedStatusType { + submission_uuid: string; + status_type_code: string; + effective_date: Date; + label: string; +} diff --git a/services/apps/alcs/src/portal/public/search/public-search.module.ts b/services/apps/alcs/src/portal/public/search/public-search.module.ts new file mode 100644 index 0000000000..25d05f80b2 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Application } from '../../../alcs/application/application.entity'; +import { LocalGovernment } from '../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntent } from '../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { Notification } from '../../../alcs/notification/notification.entity'; +import { ApplicationProfile } from '../../../common/automapper/application.automapper.profile'; +import { PublicApplicationSearchService } from './application/public-application-search.service'; +import { PublicApplicationSubmissionSearchView } from './application/public-application-search-view.entity'; +import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; +import { PublicNoticeOfIntentSubmissionSearchView } from './notice-of-intent/public-notice-of-intent-search-view.entity'; +import { PublicNotificationSearchService } from './notification/public-notification-search.service'; +import { PublicNotificationSubmissionSearchView } from './notification/public-notification-search-view.entity'; +import { PublicSearchController } from './public-search.controller'; +import { PublicSearchService } from './public-search.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Application, + NoticeOfIntent, + Notification, + LocalGovernment, + PublicApplicationSubmissionSearchView, + PublicNoticeOfIntentSubmissionSearchView, + PublicNotificationSubmissionSearchView, + ]), + ], + providers: [ + PublicSearchService, + ApplicationProfile, + PublicApplicationSearchService, + PublicNoticeOfIntentSearchService, + PublicNotificationSearchService, + ], + controllers: [PublicSearchController], +}) +export class PublicSearchModule {} diff --git a/services/apps/alcs/src/portal/public/search/public-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/public-search.service.spec.ts new file mode 100644 index 0000000000..20e747ad8c --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.service.spec.ts @@ -0,0 +1,115 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Application } from '../../../alcs/application/application.entity'; +import { LocalGovernment } from '../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntent } from '../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { PublicApplicationSubmissionSearchView } from './application/public-application-search-view.entity'; +import { PublicSearchService } from './public-search.service'; +import { Notification } from '../../../alcs/notification/notification.entity'; + +describe('PublicSearchService', () => { + let service: PublicSearchService; + let mockApplicationRepository: DeepMocked>; + let mockNoiRepository: DeepMocked>; + let mockApplicationSubmissionSearchView: DeepMocked< + Repository + >; + let mockLocalGovernment: DeepMocked>; + let mockNotificationRepository: DeepMocked>; + + const fakeFileNumber = 'fake'; + + beforeEach(async () => { + mockApplicationRepository = createMock(); + mockNoiRepository = createMock(); + mockApplicationSubmissionSearchView = createMock(); + mockLocalGovernment = createMock(); + mockNotificationRepository = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PublicSearchService, + { + provide: getRepositoryToken(Application), + useValue: mockApplicationRepository, + }, + { + provide: getRepositoryToken(NoticeOfIntent), + useValue: mockNoiRepository, + }, + { + provide: getRepositoryToken(Notification), + useValue: mockNotificationRepository, + }, + { + provide: getRepositoryToken(PublicApplicationSubmissionSearchView), + useValue: mockApplicationSubmissionSearchView, + }, + { + provide: getRepositoryToken(LocalGovernment), + useValue: mockLocalGovernment, + }, + ], + }).compile(); + + service = module.get(PublicSearchService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should call repository to get notice of intent', async () => { + mockNoiRepository.findOne.mockResolvedValue(new NoticeOfIntent()); + + const result = await service.getNoi('fake'); + + expect(mockNoiRepository.findOne).toBeCalledTimes(1); + expect(mockNoiRepository.findOne).toBeCalledWith({ + where: { + fileNumber: fakeFileNumber, + }, + relations: { + localGovernment: true, + }, + }); + expect(result).toBeDefined(); + }); + + it('should call repository to get application', async () => { + mockApplicationRepository.findOne.mockResolvedValue(new Application()); + + const result = await service.getApplication('fake'); + + expect(mockApplicationRepository.findOne).toBeCalledTimes(1); + expect(mockApplicationRepository.findOne).toBeCalledWith({ + where: { + fileNumber: fakeFileNumber, + }, + relations: { + localGovernment: true, + type: true, + }, + }); + expect(result).toBeDefined(); + }); + + it('should call repository to get notification', async () => { + mockNotificationRepository.findOne.mockResolvedValue(new Notification()); + + const result = await service.getNotification('fake'); + + expect(mockNotificationRepository.findOne).toBeCalledTimes(1); + expect(mockNotificationRepository.findOne).toBeCalledWith({ + where: { + fileNumber: fakeFileNumber, + }, + relations: { + localGovernment: true, + }, + }); + expect(result).toBeDefined(); + }); +}); diff --git a/services/apps/alcs/src/portal/public/search/public-search.service.ts b/services/apps/alcs/src/portal/public/search/public-search.service.ts new file mode 100644 index 0000000000..0b9ae87c93 --- /dev/null +++ b/services/apps/alcs/src/portal/public/search/public-search.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Application } from '../../../alcs/application/application.entity'; +import { NoticeOfIntent } from '../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { Notification } from '../../../alcs/notification/notification.entity'; + +@Injectable() +export class PublicSearchService { + constructor( + @InjectRepository(Application) + private applicationRepository: Repository, + @InjectRepository(NoticeOfIntent) + private noiRepository: Repository, + @InjectRepository(Notification) + private notificationRepository: Repository, + ) {} + + async getApplication(fileNumber: string) { + return await this.applicationRepository.findOne({ + where: { + fileNumber, + }, + relations: { + localGovernment: true, + type: true, + }, + }); + } + + async getNoi(fileNumber: string) { + return await this.noiRepository.findOne({ + where: { + fileNumber, + }, + relations: { + localGovernment: true, + }, + }); + } + + async getNotification(fileNumber: string) { + return await this.notificationRepository.findOne({ + where: { + fileNumber, + }, + relations: { + localGovernment: true, + }, + }); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1695677471273-public_search_views.ts b/services/apps/alcs/src/providers/typeorm/migrations/1695677471273-public_search_views.ts new file mode 100644 index 0000000000..86b274a80e --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1695677471273-public_search_views.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class publicSearchViews1695677471273 implements MigrationInterface { + name = 'publicSearchViews1695677471273'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE VIEW "alcs"."public_notice_of_intent_submission_search_view" AS SELECT "noi_sub"."uuid" AS "uuid", "noi_sub"."applicant" AS "applicant", "noi"."uuid" AS "notice_of_intent_uuid", "noticeOfIntentType"."audit_deleted_date_at" AS "noticeOfIntentType_audit_deleted_date_at", "noticeOfIntentType"."audit_created_at" AS "noticeOfIntentType_audit_created_at", "noticeOfIntentType"."audit_updated_at" AS "noticeOfIntentType_audit_updated_at", "noticeOfIntentType"."audit_created_by" AS "noticeOfIntentType_audit_created_by", "noticeOfIntentType"."audit_updated_by" AS "noticeOfIntentType_audit_updated_by", "noticeOfIntentType"."label" AS "noticeOfIntentType_label", "noticeOfIntentType"."code" AS "noticeOfIntentType_code", "noticeOfIntentType"."description" AS "noticeOfIntentType_description", "noticeOfIntentType"."short_label" AS "noticeOfIntentType_short_label", "noticeOfIntentType"."background_color" AS "noticeOfIntentType_background_color", "noticeOfIntentType"."text_color" AS "noticeOfIntentType_text_color", "noticeOfIntentType"."html_description" AS "noticeOfIntentType_html_description", "noticeOfIntentType"."portal_label" AS "noticeOfIntentType_portal_label", "localGovernment"."name" AS "local_government_name", "noi_sub"."file_number" AS "file_number", "noi_sub"."local_government_uuid" AS "local_government_uuid", "noi_sub"."type_code" AS "notice_of_intent_type_code", "noi"."date_submitted_to_alc" AS "date_submitted_to_alc", "noi"."decision_date" AS "decision_date", "noi"."region_code" AS "notice_of_intent_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid") AS "status" FROM "alcs"."notice_of_intent_submission" "noi_sub" INNER JOIN "alcs"."notice_of_intent" "noi" ON "noi"."file_number" = "noi_sub"."file_number" AND "noi"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notice_of_intent_type" "noticeOfIntentType" ON "noi_sub"."type_code" = "noticeOfIntentType"."code" AND "noticeOfIntentType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noi_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."notice_of_intent_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "noi_sub"."uuid" LEFT JOIN (SELECT MAX("date") AS "date", notice_of_intent_uuid AS "notice_of_intent_uuid" FROM "alcs"."notice_of_intent_decision" "decision_date" WHERE "decision_date"."audit_deleted_date_at" IS NULL GROUP BY notice_of_intent_uuid) "decision_date" ON decision_date."notice_of_intent_uuid" = "noi"."uuid" WHERE ( "noi_sub"."is_draft" = FALSE AND "noi"."date_received_all_items" IS NOT NULL AND alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid")->>'status_type_code' != 'CNCL' ) AND ( "noi_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_notice_of_intent_submission_search_view', + 'SELECT "noi_sub"."uuid" AS "uuid", "noi_sub"."applicant" AS "applicant", "noi"."uuid" AS "notice_of_intent_uuid", "noticeOfIntentType"."audit_deleted_date_at" AS "noticeOfIntentType_audit_deleted_date_at", "noticeOfIntentType"."audit_created_at" AS "noticeOfIntentType_audit_created_at", "noticeOfIntentType"."audit_updated_at" AS "noticeOfIntentType_audit_updated_at", "noticeOfIntentType"."audit_created_by" AS "noticeOfIntentType_audit_created_by", "noticeOfIntentType"."audit_updated_by" AS "noticeOfIntentType_audit_updated_by", "noticeOfIntentType"."label" AS "noticeOfIntentType_label", "noticeOfIntentType"."code" AS "noticeOfIntentType_code", "noticeOfIntentType"."description" AS "noticeOfIntentType_description", "noticeOfIntentType"."short_label" AS "noticeOfIntentType_short_label", "noticeOfIntentType"."background_color" AS "noticeOfIntentType_background_color", "noticeOfIntentType"."text_color" AS "noticeOfIntentType_text_color", "noticeOfIntentType"."html_description" AS "noticeOfIntentType_html_description", "noticeOfIntentType"."portal_label" AS "noticeOfIntentType_portal_label", "localGovernment"."name" AS "local_government_name", "noi_sub"."file_number" AS "file_number", "noi_sub"."local_government_uuid" AS "local_government_uuid", "noi_sub"."type_code" AS "notice_of_intent_type_code", "noi"."date_submitted_to_alc" AS "date_submitted_to_alc", "noi"."decision_date" AS "decision_date", "noi"."region_code" AS "notice_of_intent_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid") AS "status" FROM "alcs"."notice_of_intent_submission" "noi_sub" INNER JOIN "alcs"."notice_of_intent" "noi" ON "noi"."file_number" = "noi_sub"."file_number" AND "noi"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notice_of_intent_type" "noticeOfIntentType" ON "noi_sub"."type_code" = "noticeOfIntentType"."code" AND "noticeOfIntentType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noi_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."notice_of_intent_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "noi_sub"."uuid" LEFT JOIN (SELECT MAX("date") AS "date", notice_of_intent_uuid AS "notice_of_intent_uuid" FROM "alcs"."notice_of_intent_decision" "decision_date" WHERE "decision_date"."audit_deleted_date_at" IS NULL GROUP BY notice_of_intent_uuid) "decision_date" ON decision_date."notice_of_intent_uuid" = "noi"."uuid" WHERE ( "noi_sub"."is_draft" = FALSE AND "noi"."date_received_all_items" IS NOT NULL AND alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid")->>\'status_type_code\' != \'CNCL\' ) AND ( "noi_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_application_submission_search_view" AS SELECT "app_sub"."uuid" AS "uuid", "app_sub"."applicant" AS "applicant", "app"."uuid" AS "application_uuid", "applicationType"."audit_deleted_date_at" AS "applicationType_audit_deleted_date_at", "applicationType"."audit_created_at" AS "applicationType_audit_created_at", "applicationType"."audit_updated_at" AS "applicationType_audit_updated_at", "applicationType"."audit_created_by" AS "applicationType_audit_created_by", "applicationType"."audit_updated_by" AS "applicationType_audit_updated_by", "applicationType"."label" AS "applicationType_label", "applicationType"."code" AS "applicationType_code", "applicationType"."description" AS "applicationType_description", "applicationType"."short_label" AS "applicationType_short_label", "applicationType"."background_color" AS "applicationType_background_color", "applicationType"."text_color" AS "applicationType_text_color", "applicationType"."html_description" AS "applicationType_html_description", "applicationType"."portal_label" AS "applicationType_portal_label", "localGovernment"."name" AS "local_government_name", "app_sub"."file_number" AS "file_number", "app_sub"."local_government_uuid" AS "local_government_uuid", "app_sub"."type_code" AS "application_type_code", "app"."date_submitted_to_alc" AS "date_submitted_to_alc", "app"."decision_date" AS "decision_date", "app"."region_code" AS "application_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid") AS "status" FROM "alcs"."application_submission" "app_sub" INNER JOIN "alcs"."application" "app" ON "app"."file_number" = "app_sub"."file_number" AND "app"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."application_type" "applicationType" ON "app_sub"."type_code" = "applicationType"."code" AND "applicationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "app_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."application_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "app_sub"."uuid" LEFT JOIN (SELECT MAX("date") AS "date", application_uuid AS "application_uuid" FROM "alcs"."application_decision" "decision_date" WHERE "decision_date"."audit_deleted_date_at" IS NULL GROUP BY application_uuid) "decision_date" ON decision_date."application_uuid" = "app"."uuid" WHERE ( "app_sub"."is_draft" = FALSE AND "app"."date_received_all_items" IS NOT NULL AND alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid")->>'status_type_code' != 'CNCL' ) AND ( "app_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_application_submission_search_view', + 'SELECT "app_sub"."uuid" AS "uuid", "app_sub"."applicant" AS "applicant", "app"."uuid" AS "application_uuid", "applicationType"."audit_deleted_date_at" AS "applicationType_audit_deleted_date_at", "applicationType"."audit_created_at" AS "applicationType_audit_created_at", "applicationType"."audit_updated_at" AS "applicationType_audit_updated_at", "applicationType"."audit_created_by" AS "applicationType_audit_created_by", "applicationType"."audit_updated_by" AS "applicationType_audit_updated_by", "applicationType"."label" AS "applicationType_label", "applicationType"."code" AS "applicationType_code", "applicationType"."description" AS "applicationType_description", "applicationType"."short_label" AS "applicationType_short_label", "applicationType"."background_color" AS "applicationType_background_color", "applicationType"."text_color" AS "applicationType_text_color", "applicationType"."html_description" AS "applicationType_html_description", "applicationType"."portal_label" AS "applicationType_portal_label", "localGovernment"."name" AS "local_government_name", "app_sub"."file_number" AS "file_number", "app_sub"."local_government_uuid" AS "local_government_uuid", "app_sub"."type_code" AS "application_type_code", "app"."date_submitted_to_alc" AS "date_submitted_to_alc", "app"."decision_date" AS "decision_date", "app"."region_code" AS "application_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid") AS "status" FROM "alcs"."application_submission" "app_sub" INNER JOIN "alcs"."application" "app" ON "app"."file_number" = "app_sub"."file_number" AND "app"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."application_type" "applicationType" ON "app_sub"."type_code" = "applicationType"."code" AND "applicationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "app_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."application_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "app_sub"."uuid" LEFT JOIN (SELECT MAX("date") AS "date", application_uuid AS "application_uuid" FROM "alcs"."application_decision" "decision_date" WHERE "decision_date"."audit_deleted_date_at" IS NULL GROUP BY application_uuid) "decision_date" ON decision_date."application_uuid" = "app"."uuid" WHERE ( "app_sub"."is_draft" = FALSE AND "app"."date_received_all_items" IS NOT NULL AND alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid")->>\'status_type_code\' != \'CNCL\' ) AND ( "app_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_notification_submission_search_view" AS SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "noti"."uuid" AS "notification_uuid", "notificationType"."audit_deleted_date_at" AS "notificationType_audit_deleted_date_at", "notificationType"."audit_created_at" AS "notificationType_audit_created_at", "notificationType"."audit_updated_at" AS "notificationType_audit_updated_at", "notificationType"."audit_created_by" AS "notificationType_audit_created_by", "notificationType"."audit_updated_by" AS "notificationType_audit_updated_by", "notificationType"."label" AS "notificationType_label", "notificationType"."code" AS "notificationType_code", "notificationType"."description" AS "notificationType_description", "notificationType"."short_label" AS "notificationType_short_label", "notificationType"."background_color" AS "notificationType_background_color", "notificationType"."text_color" AS "notificationType_text_color", "notificationType"."html_description" AS "notificationType_html_description", "notificationType"."portal_label" AS "notificationType_portal_label", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti_sub"."local_government_uuid" AS "local_government_uuid", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", "noti"."region_code" AS "notification_region_code", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notification_type" "notificationType" ON "noti_sub"."type_code" = "notificationType"."code" AND "notificationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>'status_type_code' = 'ALCR' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_notification_submission_search_view', + 'SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "noti"."uuid" AS "notification_uuid", "notificationType"."audit_deleted_date_at" AS "notificationType_audit_deleted_date_at", "notificationType"."audit_created_at" AS "notificationType_audit_created_at", "notificationType"."audit_updated_at" AS "notificationType_audit_updated_at", "notificationType"."audit_created_by" AS "notificationType_audit_created_by", "notificationType"."audit_updated_by" AS "notificationType_audit_updated_by", "notificationType"."label" AS "notificationType_label", "notificationType"."code" AS "notificationType_code", "notificationType"."description" AS "notificationType_description", "notificationType"."short_label" AS "notificationType_short_label", "notificationType"."background_color" AS "notificationType_background_color", "notificationType"."text_color" AS "notificationType_text_color", "notificationType"."html_description" AS "notificationType_html_description", "notificationType"."portal_label" AS "notificationType_portal_label", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti_sub"."local_government_uuid" AS "local_government_uuid", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", "noti"."region_code" AS "notification_region_code", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notification_type" "notificationType" ON "noti_sub"."type_code" = "notificationType"."code" AND "notificationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>\'status_type_code\' = \'ALCR\' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notification_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notification_submission_search_view"`, + ); + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_application_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_application_submission_search_view"`, + ); + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notice_of_intent_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notice_of_intent_submission_search_view"`, + ); + } +}