diff --git a/express-api/src/services/properties/propertiesServices.ts b/express-api/src/services/properties/propertiesServices.ts index dee5408e3..dfca49fb2 100644 --- a/express-api/src/services/properties/propertiesServices.ts +++ b/express-api/src/services/properties/propertiesServices.ts @@ -32,6 +32,9 @@ import userServices from '../users/usersServices'; import { Brackets, FindManyOptions, FindOptionsWhere, ILike, In, QueryRunner } from 'typeorm'; import { SSOUser } from '@bcgov/citz-imb-sso-express'; import { PropertyType } from '@/constants/propertyType'; +import { ProjectStatus } from '@/constants/projectStatus'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; +import { ProjectStatus as ProjectStatusEntity } from '@/typeorm/Entities/ProjectStatus'; import { parentPort } from 'worker_threads'; /** @@ -42,6 +45,33 @@ import { parentPort } from 'worker_threads'; * @returns An object containing the found parcels and buildings that match the search criteria. */ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds?: number[]) => { + const allStatusIds = (await AppDataSource.getRepository(ProjectStatusEntity).find()).map( + (i) => i.Id, + ); + const allowedStatusIds = [ + ProjectStatus.CANCELLED, + ProjectStatus.DENIED, + ProjectStatus.TRANSFERRED_WITHIN_GRE, + ]; + const disallowedStatusIds = allStatusIds.filter((s) => !allowedStatusIds.includes(s)); + + // Find all properties that are attached to projects in states other than Cancelled, Transferred within GRE, or Denied + // Get project properties that are in projects currently in the disallowed statuses + const excludedIds = await AppDataSource.getRepository(ProjectProperty).find({ + relations: { + Project: true, + }, + where: { + Project: { + StatusId: In(disallowedStatusIds), + }, + }, + }); + + const excludedParcelIds = excludedIds.map((row) => row.ParcelId).filter((id) => id != null); + + const excludedBuildingIds = excludedIds.map((row) => row.BuildingId).filter((id) => id != null); + const parcelsQuery = await AppDataSource.getRepository(Parcel) .createQueryBuilder('parcel') .leftJoinAndSelect('parcel.Agency', 'agency') @@ -49,6 +79,7 @@ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds? .leftJoinAndSelect('parcel.Evaluations', 'evaluations') .leftJoinAndSelect('parcel.Fiscals', 'fiscals') .leftJoinAndSelect('parcel.Classification', 'classification') + // Match the search criteria .where( new Brackets((qb) => { qb.where(`LPAD(parcel.pid::text, 9, '0') ILIKE '%${keyword.replaceAll('-', '')}%'`) @@ -58,7 +89,10 @@ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds? .orWhere(`parcel.address1 ILIKE '%${keyword}%'`); }), ) - .andWhere(`classification.Name in ('Surplus Encumbered', 'Surplus Active')`); + // Only include surplus properties + .andWhere(`classification.Name in ('Surplus Encumbered', 'Surplus Active')`) + // Exclude if already is a project property in a project that's in a disallowed status + .andWhere(`parcel.id NOT IN(:...excludedParcelIds)`, { excludedParcelIds }); // Add the optional agencyIds filter if provided if (agencyIds && agencyIds.length > 0) { @@ -76,16 +110,20 @@ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds? .leftJoinAndSelect('building.Evaluations', 'evaluations') .leftJoinAndSelect('building.Fiscals', 'fiscals') .leftJoinAndSelect('building.Classification', 'classification') + // Match the search criteria .where( new Brackets((qb) => { - qb.where(`building.pid::text like :keyword`, { keyword: `%${keyword}%` }) - .orWhere(`building.pin::text like :keyword`, { keyword: `%${keyword}%` }) - .orWhere(`agency.name like :keyword`, { keyword: `%${keyword}%` }) - .orWhere(`adminArea.name like :keyword`, { keyword: `%${keyword}%` }) - .orWhere(`building.address1 like :keyword`, { keyword: `%${keyword}%` }); + qb.where(`LPAD(building.pid::text, 9, '0') ILIKE '%${keyword.replaceAll('-', '')}%'`) + .orWhere(`building.pin::text ILIKE '%${keyword}%'`) + .orWhere(`agency.name ILIKE '%${keyword}%'`) + .orWhere(`adminArea.name ILIKE '%${keyword}%'`) + .orWhere(`building.address1 ILIKE '%${keyword}%'`); }), ) - .andWhere(`classification.Name in ('Surplus Encumbered', 'Surplus Active')`); + // Only include surplus properties + .andWhere(`classification.Name in ('Surplus Encumbered', 'Surplus Active')`) + // Exclude if already is a project property in a project that's in a disallowed status + .andWhere(`building.id NOT IN(:...excludedBuildingIds)`, { excludedBuildingIds }); if (agencyIds && agencyIds.length > 0) { buildingsQuery.andWhere(`building.agency_id IN (:...agencyIds)`, { agencyIds }); diff --git a/express-api/tests/unit/services/properties/propertyServices.test.ts b/express-api/tests/unit/services/properties/propertyServices.test.ts index 46f27d311..c958cfc24 100644 --- a/express-api/tests/unit/services/properties/propertyServices.test.ts +++ b/express-api/tests/unit/services/properties/propertyServices.test.ts @@ -17,6 +17,8 @@ import { ImportResult } from '@/typeorm/Entities/ImportResult'; import { Parcel } from '@/typeorm/Entities/Parcel'; import { ParcelEvaluation } from '@/typeorm/Entities/ParcelEvaluation'; import { ParcelFiscal } from '@/typeorm/Entities/ParcelFiscal'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; +import { ProjectStatus } from '@/typeorm/Entities/ProjectStatus'; import { PropertyClassification } from '@/typeorm/Entities/PropertyClassification'; import { User } from '@/typeorm/Entities/User'; import { MapProperties } from '@/typeorm/Entities/views/MapPropertiesView'; @@ -38,6 +40,8 @@ import { produceBuildingEvaluation, produceBuildingFiscal, produceSSO, + produceProjectStatus, + produceProjectProperty, } from 'tests/testUtils/factories'; import { DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm'; import xlsx, { WorkSheet } from 'xlsx'; @@ -82,6 +86,46 @@ const _propertyUnionCreateQueryBuilder: any = { getManyAndCount: () => [[producePropertyUnion()], 1], }; +const _projectStatusCreateQueryBuilder: any = { + select: () => _projectStatusCreateQueryBuilder, + leftJoinAndSelect: () => _projectStatusCreateQueryBuilder, + where: () => _projectStatusCreateQueryBuilder, + orWhere: () => _projectStatusCreateQueryBuilder, + andWhere: () => _projectStatusCreateQueryBuilder, + take: () => _projectStatusCreateQueryBuilder, + skip: () => _projectStatusCreateQueryBuilder, + orderBy: () => _projectStatusCreateQueryBuilder, + getMany: () => [produceProjectStatus()], +}; + +const _projectPropertyCreateQueryBuilder: any = { + select: () => _projectPropertyCreateQueryBuilder, + leftJoinAndSelect: () => _projectPropertyCreateQueryBuilder, + where: () => _projectPropertyCreateQueryBuilder, + orWhere: () => _projectPropertyCreateQueryBuilder, + andWhere: () => _projectPropertyCreateQueryBuilder, + take: () => _projectPropertyCreateQueryBuilder, + skip: () => _projectPropertyCreateQueryBuilder, + orderBy: () => _projectPropertyCreateQueryBuilder, + getMany: () => [produceProjectProperty()], +}; + +jest + .spyOn(AppDataSource.getRepository(ProjectProperty), 'createQueryBuilder') + .mockImplementation(() => _projectPropertyCreateQueryBuilder); + +jest + .spyOn(AppDataSource.getRepository(ProjectProperty), 'find') + .mockImplementation(async () => [produceProjectProperty()]); + +jest + .spyOn(AppDataSource.getRepository(ProjectStatus), 'createQueryBuilder') + .mockImplementation(() => _projectStatusCreateQueryBuilder); + +jest + .spyOn(AppDataSource.getRepository(ProjectStatus), 'find') + .mockImplementation(async () => [produceProjectStatus()]); + jest .spyOn(AppDataSource.getRepository(Parcel), 'createQueryBuilder') .mockImplementation(() => _parcelsCreateQueryBuilder);