diff --git a/express-api/src/constants/projectStatus.ts b/express-api/src/constants/projectStatus.ts index 469ff12e6e..788c4447bc 100644 --- a/express-api/src/constants/projectStatus.ts +++ b/express-api/src/constants/projectStatus.ts @@ -31,3 +31,9 @@ export enum ProjectStatus { // CONTRACT_IN_PLACE_UNCONDITIONAL = 43, // Disabled CLOSE_OUT = 44, } + +/** + * Projects and properties in ERP are shown to agencies outside of their owning agency. + * Adding new statuses to this list will reveal them to outside agencies. + */ +export const exposedProjectStatuses = [ProjectStatus.APPROVED_FOR_ERP]; diff --git a/express-api/src/controllers/buildings/buildingsController.ts b/express-api/src/controllers/buildings/buildingsController.ts index 89a28302b4..fb22ee6c4d 100644 --- a/express-api/src/controllers/buildings/buildingsController.ts +++ b/express-api/src/controllers/buildings/buildingsController.ts @@ -6,6 +6,9 @@ import { SSOUser } from '@bcgov/citz-imb-sso-express'; import { Building } from '@/typeorm/Entities/Building'; import { checkUserAgencyPermission, isAdmin, isAuditor } from '@/utilities/authorizationChecks'; import { Roles } from '@/constants/roles'; +import { AppDataSource } from '@/appDataSource'; +import { exposedProjectStatuses } from '@/constants/projectStatus'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; /** * @description Gets all buildings satisfying the filter parameters. @@ -53,7 +56,28 @@ export const getBuilding = async (req: Request, res: Response) => { if (!building) { return res.status(404).send('Building matching this ID was not found.'); - } else if (!(await checkUserAgencyPermission(kcUser, [building.AgencyId], permittedRoles))) { + } + + // Get related projects + const projects = ( + await AppDataSource.getRepository(ProjectProperty).find({ + where: { + BuildingId: building.Id, + }, + relations: { + Project: true, + }, + }) + ).map((pp) => pp.Project); + // Are any related projects in ERP? If so, they should be visible to outside agencies. + const isVisibleToOtherAgencies = projects.some((project) => + exposedProjectStatuses.includes(project.StatusId), + ); + + if ( + !(await checkUserAgencyPermission(kcUser, [building.AgencyId], permittedRoles)) && + !isVisibleToOtherAgencies + ) { return res.status(403).send('You are not authorized to view this building.'); } return res.status(200).send(building); diff --git a/express-api/src/controllers/parcels/parcelsController.ts b/express-api/src/controllers/parcels/parcelsController.ts index 265605e5ba..6f68cb69fb 100644 --- a/express-api/src/controllers/parcels/parcelsController.ts +++ b/express-api/src/controllers/parcels/parcelsController.ts @@ -6,6 +6,9 @@ import userServices from '@/services/users/usersServices'; import { Parcel } from '@/typeorm/Entities/Parcel'; import { Roles } from '@/constants/roles'; import { checkUserAgencyPermission, isAdmin, isAuditor } from '@/utilities/authorizationChecks'; +import { AppDataSource } from '@/appDataSource'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; +import { exposedProjectStatuses } from '@/constants/projectStatus'; /** * @description Gets information about a particular parcel by the Id provided in the URL parameter. @@ -23,9 +26,31 @@ export const getParcel = async (req: Request, res: Response) => { const permittedRoles = [Roles.ADMIN, Roles.AUDITOR]; const kcUser = req.user as unknown as SSOUser; const parcel = await parcelServices.getParcelById(parcelId); + if (!parcel) { return res.status(404).send('Parcel matching this internal ID not found.'); - } else if (!(await checkUserAgencyPermission(kcUser, [parcel.AgencyId], permittedRoles))) { + } + + // Get related projects + const projects = ( + await AppDataSource.getRepository(ProjectProperty).find({ + where: { + ParcelId: parcel.Id, + }, + relations: { + Project: true, + }, + }) + ).map((pp) => pp.Project); + // Are any related projects in ERP? If so, they should be visible to outside agencies. + const isVisibleToOtherAgencies = projects.some((project) => + exposedProjectStatuses.includes(project.StatusId), + ); + + if ( + !(await checkUserAgencyPermission(kcUser, [parcel.AgencyId], permittedRoles)) && + !isVisibleToOtherAgencies + ) { return res.status(403).send('You are not authorized to view this parcel.'); } return res.status(200).send(parcel); diff --git a/express-api/src/controllers/projects/projectsController.ts b/express-api/src/controllers/projects/projectsController.ts index 09711d6c2b..60c59c1411 100644 --- a/express-api/src/controllers/projects/projectsController.ts +++ b/express-api/src/controllers/projects/projectsController.ts @@ -8,6 +8,7 @@ import { DeepPartial } from 'typeorm'; import { Project } from '@/typeorm/Entities/Project'; import { Roles } from '@/constants/roles'; import notificationServices from '@/services/notifications/notificationServices'; +import { exposedProjectStatuses } from '@/constants/projectStatus'; /** * @description Get disposal project by either the numeric id or projectNumber. @@ -28,7 +29,13 @@ export const getDisposalProject = async (req: Request, res: Response) => { return res.status(404).send('Project matching this internal ID not found.'); } - if (!(await checkUserAgencyPermission(user, [project.AgencyId], permittedRoles))) { + // Is the project in ERP? If so, it should be visible to outside agencies. + const isVisibleToOtherAgencies = exposedProjectStatuses.includes(project.StatusId); + + if ( + !(await checkUserAgencyPermission(user, [project.AgencyId], permittedRoles)) && + !isVisibleToOtherAgencies + ) { return res.status(403).send('You are not authorized to view this project.'); } diff --git a/express-api/src/controllers/properties/propertiesController.ts b/express-api/src/controllers/properties/propertiesController.ts index e02fdaf28b..91bd8b4a8e 100644 --- a/express-api/src/controllers/properties/propertiesController.ts +++ b/express-api/src/controllers/properties/propertiesController.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express'; import propertyServices from '@/services/properties/propertiesServices'; import { ImportResultFilterSchema, + MapFilter, MapFilterSchema, PropertyUnionFilterSchema, } from '@/controllers/properties/propertiesSchema'; @@ -68,7 +69,7 @@ export const getPropertiesForMap = async (req: Request, res: Response) => { // Converts comma-separated lists to arrays, see schema // Must remove empty arrays for TypeORM to work - const filterResult = { + const filterResult: MapFilter = { ...filter.data, AgencyIds: filter.data.AgencyIds.length ? filter.data.AgencyIds : undefined, ClassificationIds: filter.data.ClassificationIds.length @@ -81,11 +82,12 @@ export const getPropertiesForMap = async (req: Request, res: Response) => { RegionalDistrictIds: filter.data.RegionalDistrictIds.length ? filter.data.RegionalDistrictIds : undefined, + // UserAgencies included to separate requested filter on agencies vs user's restriction on agencies + UserAgencies: undefined, }; // Controlling for agency search visibility const kcUser = req.user; - // admin and suditors can see any property const permittedRoles = [Roles.ADMIN, Roles.AUDITOR]; // Admins and auditors see all, otherwise... if (!(isAdmin(kcUser) || isAuditor(kcUser))) { @@ -99,7 +101,7 @@ export const getPropertiesForMap = async (req: Request, res: Response) => { if (!requestedAgencies || !userHasAgencies) { // Then only show that user's agencies instead. const usersAgencies = await userServices.getAgencies(kcUser.preferred_username); - filterResult.AgencyIds = usersAgencies; + filterResult.UserAgencies = usersAgencies; } } diff --git a/express-api/src/controllers/properties/propertiesSchema.ts b/express-api/src/controllers/properties/propertiesSchema.ts index 3edd14c95f..5e611e3be8 100644 --- a/express-api/src/controllers/properties/propertiesSchema.ts +++ b/express-api/src/controllers/properties/propertiesSchema.ts @@ -32,6 +32,7 @@ export const MapFilterSchema = z.object({ PropertyTypeIds: arrayFromString(numberSchema), Name: z.string().optional(), RegionalDistrictIds: arrayFromString(numberSchema), + UserAgencies: z.array(z.number().int()).optional(), }); export type MapFilter = z.infer; @@ -47,6 +48,7 @@ export const PropertyUnionFilterSchema = z.object({ administrativeArea: z.string().optional(), landArea: z.string().optional(), updatedOn: z.string().optional(), + projectStatus: z.string().optional(), quickFilter: z.string().optional(), sortKey: z.string().optional(), sortOrder: z.string().optional(), diff --git a/express-api/src/services/projects/projectsServices.ts b/express-api/src/services/projects/projectsServices.ts index 83be1ddebb..3c425daef8 100644 --- a/express-api/src/services/projects/projectsServices.ts +++ b/express-api/src/services/projects/projectsServices.ts @@ -1,5 +1,5 @@ import { AppDataSource } from '@/appDataSource'; -import { ProjectStatus } from '@/constants/projectStatus'; +import { exposedProjectStatuses, ProjectStatus } from '@/constants/projectStatus'; import { ProjectType } from '@/constants/projectType'; import { Agency } from '@/typeorm/Entities/Agency'; import { Building } from '@/typeorm/Entities/Building'; @@ -925,11 +925,20 @@ const getProjects = async (filter: ProjectFilter) => { }), ); - // Restricts based on user's agencies + // Only non-admins have this set in the controller if (filter.agencyId?.length) { - query.andWhere('agency_id IN(:...list)', { - list: filter.agencyId, - }); + query.andWhere( + new Brackets((qb) => { + // Restricts based on user's agencies + qb.orWhere('agency_id IN(:...list)', { + list: filter.agencyId, + }); + // But also allow for ERP projects to be visible + qb.orWhere('status_id IN(:...exposedProjectStatuses)', { + exposedProjectStatuses: exposedProjectStatuses, + }); + }), + ); } // Add quickfilter part diff --git a/express-api/src/services/properties/propertiesServices.ts b/express-api/src/services/properties/propertiesServices.ts index 1aed02d514..54abf50a60 100644 --- a/express-api/src/services/properties/propertiesServices.ts +++ b/express-api/src/services/properties/propertiesServices.ts @@ -33,7 +33,7 @@ import userServices from '../users/usersServices'; import { Brackets, 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 { exposedProjectStatuses, ProjectStatus } from '@/constants/projectStatus'; import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; import { ProjectStatus as ProjectStatusEntity } from '@/typeorm/Entities/ProjectStatus'; import { parentPort } from 'worker_threads'; @@ -182,35 +182,79 @@ const findLinkedProjectsForProperty = async (buildingId?: number, parcelId?: num * @returns A promise that resolves to an array of properties matching the filter criteria. */ const getPropertiesForMap = async (filter?: MapFilter) => { - const properties = await AppDataSource.getRepository(MapProperties).find({ - // Select only the properties needed to render map markers and sidebar - select: { - Id: true, - Location: { - x: true, - y: true, - }, - PropertyTypeId: true, - ClassificationId: true, - Name: true, - PID: true, - PIN: true, - AdministrativeAreaId: true, - AgencyId: true, - Address1: true, + // Select only the properties needed to render map markers and sidebar + const selectObject = { + Id: true, + Location: { + x: true, + y: true, }, + PropertyTypeId: true, + ClassificationId: true, + Name: true, + PID: true, + PIN: true, + AdministrativeAreaId: true, + AgencyId: true, + Address1: true, + ProjectStatusId: true, + }; + + const filterBase: FindOptionsWhere = { + ClassificationId: filter.ClassificationIds ? In(filter.ClassificationIds) : undefined, + AdministrativeAreaId: filter.AdministrativeAreaIds + ? In(filter.AdministrativeAreaIds) + : undefined, + PID: filter.PID, + PIN: filter.PIN, + Address1: filter.Address ? ILike(`%${filter.Address}%`) : undefined, + Name: filter.Name ? ILike(`%${filter.Name}%`) : undefined, + PropertyTypeId: filter.PropertyTypeIds ? In(filter.PropertyTypeIds) : undefined, + RegionalDistrictId: filter.RegionalDistrictIds ? In(filter.RegionalDistrictIds) : undefined, + }; + + /** + * If the user's agencies were defined, then they didn't have permissions to see all the agencies. + * This path allows a user to filter by agencies they belong to. + * If no agency filter is requested, it filters by the user's agencies, but also + * includes properties with a project status that would expose them to users + * outside of the owning agency. + */ + if (filter.UserAgencies) { + // Did they request to filter on agencies? Only use the crossover of their agencies and the filter + const agencies = filter.AgencyIds + ? filter.AgencyIds.filter((a) => filter.UserAgencies.includes(a)) + : filter.UserAgencies; + + const properties = await AppDataSource.getRepository(MapProperties).find({ + select: selectObject, + where: filter.AgencyIds + ? { + ...filterBase, + AgencyId: In(agencies), + } + : [ + { + ...filterBase, + AgencyId: In(agencies), + }, + { + ...filterBase, + ProjectStatusId: In(exposedProjectStatuses), + }, + ], + }); + return properties; + } + /** + * This path is for users that pass the admin/auditor role check. + * Search will function unchanged from the request. + */ + const properties = await AppDataSource.getRepository(MapProperties).find({ + select: selectObject, where: { - ClassificationId: filter.ClassificationIds ? In(filter.ClassificationIds) : undefined, + ...filterBase, AgencyId: filter.AgencyIds ? In(filter.AgencyIds) : undefined, - AdministrativeAreaId: filter.AdministrativeAreaIds - ? In(filter.AdministrativeAreaIds) - : undefined, - PID: filter.PID, - PIN: filter.PIN, - Address1: filter.Address ? ILike(`%${filter.Address}%`) : undefined, - Name: filter.Name ? ILike(`%${filter.Name}%`) : undefined, - PropertyTypeId: filter.PropertyTypeIds ? In(filter.PropertyTypeIds) : undefined, - RegionalDistrictId: filter.RegionalDistrictIds ? In(filter.RegionalDistrictIds) : undefined, }, }); return properties; @@ -799,6 +843,8 @@ const collectFindOptions = (filter: PropertyUnionFilter) => { ); if (filter.propertyType) options.push(constructFindOptionFromQuerySingleSelect('PropertyType', filter.propertyType)); + if (filter.projectStatus) + options.push(constructFindOptionFromQuerySingleSelect('ProjectStatus', filter.projectStatus)); return options; }; @@ -817,11 +863,20 @@ const getPropertiesUnion = async (filter: PropertyUnionFilter) => { }), ); - // Restricts based on user's agencies + // Only non-admins have this set in the controller if (filter.agencyIds?.length) { - query.andWhere('agency_id IN(:...list)', { - list: filter.agencyIds, - }); + query.andWhere( + new Brackets((qb) => { + // Restricts based on user's agencies + qb.orWhere('agency_id IN(:...list)', { + list: filter.agencyIds, + }); + // But also allow for ERP projects to be visible + qb.orWhere('project_status_id IN(:...exposedProjectStatuses)', { + exposedProjectStatuses: exposedProjectStatuses, + }); + }), + ); } // Add quickfilter part diff --git a/express-api/src/typeorm/Entities/views/MapPropertiesView.ts b/express-api/src/typeorm/Entities/views/MapPropertiesView.ts index fa591ba083..54752000e8 100644 --- a/express-api/src/typeorm/Entities/views/MapPropertiesView.ts +++ b/express-api/src/typeorm/Entities/views/MapPropertiesView.ts @@ -4,15 +4,70 @@ import { ViewColumn, ViewEntity } from 'typeorm'; @ViewEntity({ materialized: false, expression: ` - SELECT c.id, c.pid, c.pin, c.location, c.property_type_id, c.address1, c.classification_id, c.agency_id, c.administrative_area_id, c.name, aa.regional_district_id as regional_district_id - FROM ( - SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name - FROM parcel WHERE deleted_on IS NULL - UNION ALL - SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name - FROM building WHERE deleted_on IS NULL - ) c - LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id; + SELECT c.id, + c.pid, + c.pin, + c.location, + c.property_type_id, + c.address1, + c.classification_id, + c.agency_id, + c.administrative_area_id, + c.name, + aa.regional_district_id as regional_district_id, + c.project_status_id + FROM + (SELECT p.id, + p.pid, + p.pin, + p.location, + p.property_type_id, + p.address1, + p.classification_id, + p.agency_id, + p.administrative_area_id, + p.name, + proj.status_id as project_status_id + FROM parcel p + LEFT JOIN + (SELECT pp.parcel_id, + pp.id, + pp.project_id + FROM project_property pp + INNER JOIN + (SELECT parcel_id, + MAX(updated_on) AS max_updated_on + FROM project_property + GROUP BY parcel_id) pp_max ON pp.parcel_id = pp_max.parcel_id + AND pp.updated_on = pp_max.max_updated_on) pp_recent ON p.id = pp_recent.parcel_id + LEFT JOIN project proj ON proj.id = pp_recent.project_id + WHERE p.deleted_on IS NULL + UNION ALL SELECT b.id, + b.pid, + b.pin, + b.location, + b.property_type_id, + b.address1, + b.classification_id, + b.agency_id, + b.administrative_area_id, + b.name, + proj.status_id as project_status_id + FROM building b + LEFT JOIN + (SELECT pp.building_id, + pp.id, + pp.project_id + FROM project_property pp + INNER JOIN + (SELECT building_id, + MAX(updated_on) AS max_updated_on + FROM project_property + GROUP BY building_id) pp_max ON pp.building_id = pp_max.building_id + AND pp.updated_on = pp_max.max_updated_on) pp_recent ON b.id = pp_recent.building_id + LEFT JOIN project proj ON proj.id = pp_recent.project_id + WHERE b.deleted_on IS NULL ) c + LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id; `, }) export class MapProperties { @@ -48,4 +103,7 @@ export class MapProperties { @ViewColumn({ name: 'regional_district_id' }) RegionalDistrictId: number; + + @ViewColumn({ name: 'project_status_id' }) + ProjectStatusId: number; } diff --git a/express-api/src/typeorm/Entities/views/PropertyUnionView.ts b/express-api/src/typeorm/Entities/views/PropertyUnionView.ts index 3da11d79a2..9b29cd09b4 100644 --- a/express-api/src/typeorm/Entities/views/PropertyUnionView.ts +++ b/express-api/src/typeorm/Entities/views/PropertyUnionView.ts @@ -4,44 +4,86 @@ import { ViewColumn, ViewEntity } from 'typeorm'; materialized: false, expression: `WITH property AS (SELECT 'Parcel' AS property_type, - property_type_id, - id, - classification_id, - pid, - pin, - agency_id, - address1, - administrative_area_id, - is_sensitive, - updated_on, - land_area + p.property_type_id, + p.id, + p.classification_id, + p.pid, + p.pin, + p.agency_id, + p.address1, + p.administrative_area_id, + p.is_sensitive, + p.updated_on, + p.land_area, + proj.status_id AS project_status_id FROM parcel p -WHERE deleted_on IS NULL +LEFT JOIN ( + SELECT + pp.parcel_id, + pp.id, + pp.project_id + FROM + project_property pp + INNER JOIN ( + SELECT + parcel_id, + MAX(updated_on) AS max_updated_on + FROM + project_property + GROUP BY + parcel_id + ) pp_max ON pp.parcel_id = pp_max.parcel_id + AND pp.updated_on = pp_max.max_updated_on +) pp_recent ON p.id = pp_recent.parcel_id +LEFT JOIN project proj ON proj.id = pp_recent.project_id +WHERE p.deleted_on IS NULL UNION ALL SELECT 'Building' AS property_type, - property_type_id, - id, - classification_id, - pid, - pin, - agency_id, - address1, - administrative_area_id, - is_sensitive, - updated_on, - NULL AS land_area + b.property_type_id, + b.id, + b.classification_id, + b.pid, + b.pin, + b.agency_id, + b.address1, + b.administrative_area_id, + b.is_sensitive, + b.updated_on, + NULL AS land_area, + proj.status_id AS project_status_id FROM building b -WHERE deleted_on IS NULL) +LEFT JOIN ( + SELECT + pp.building_id, + pp.id, + pp.project_id + FROM + project_property pp + INNER JOIN ( + SELECT + building_id, + MAX(updated_on) AS max_updated_on + FROM + project_property + GROUP BY + building_id + ) pp_max ON pp.building_id = pp_max.building_id + AND pp.updated_on = pp_max.max_updated_on +) pp_recent ON b.id = pp_recent.building_id +LEFT JOIN project proj ON proj.id = pp_recent.project_id +WHERE b.deleted_on IS NULL) SELECT property.*, agc."name" AS agency_name, aa."name" AS administrative_area_name, - pc."name" AS property_classification_name + pc."name" AS property_classification_name, + ps."name" AS project_status_name FROM property LEFT JOIN agency agc ON property.agency_id = agc.id LEFT JOIN administrative_area aa ON property.administrative_area_id = aa.id - LEFT JOIN property_classification pc ON property.classification_id = pc.id;`, + LEFT JOIN property_classification pc ON property.classification_id = pc.id + LEFT JOIN project_status ps ON property.project_status_id = ps.id;`, }) export class PropertyUnion { @ViewColumn({ name: 'id' }) @@ -88,4 +130,10 @@ export class PropertyUnion { @ViewColumn({ name: 'land_area' }) LandArea: number; + + @ViewColumn({ name: 'project_status_id' }) + ProjectStatusId: number; + + @ViewColumn({ name: 'project_status_name' }) + ProjectStatus: string; } diff --git a/express-api/src/typeorm/Migrations/1725660964770-AddProjectStatusToPropertyViews.ts b/express-api/src/typeorm/Migrations/1725660964770-AddProjectStatusToPropertyViews.ts new file mode 100644 index 0000000000..83976aec32 --- /dev/null +++ b/express-api/src/typeorm/Migrations/1725660964770-AddProjectStatusToPropertyViews.ts @@ -0,0 +1,264 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProjectStatusToPropertyViews1725660964770 implements MigrationInterface { + name = 'AddProjectStatusToPropertyViews1725660964770'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'property_union', 'public'], + ); + await queryRunner.query(`DROP VIEW "property_union"`); + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'map_properties', 'public'], + ); + await queryRunner.query(`DROP VIEW "map_properties"`); + await queryRunner.query(`CREATE VIEW "map_properties" AS + SELECT c.id, + c.pid, + c.pin, + c.location, + c.property_type_id, + c.address1, + c.classification_id, + c.agency_id, + c.administrative_area_id, + c.name, + aa.regional_district_id as regional_district_id, + c.project_status_id + FROM + (SELECT p.id, + p.pid, + p.pin, + p.location, + p.property_type_id, + p.address1, + p.classification_id, + p.agency_id, + p.administrative_area_id, + p.name, + proj.status_id as project_status_id + FROM parcel p + LEFT JOIN + (SELECT pp.parcel_id, + pp.id, + pp.project_id + FROM project_property pp + INNER JOIN + (SELECT parcel_id, + MAX(updated_on) AS max_updated_on + FROM project_property + GROUP BY parcel_id) pp_max ON pp.parcel_id = pp_max.parcel_id + AND pp.updated_on = pp_max.max_updated_on) pp_recent ON p.id = pp_recent.parcel_id + LEFT JOIN project proj ON proj.id = pp_recent.project_id + WHERE p.deleted_on IS NULL + UNION ALL SELECT b.id, + b.pid, + b.pin, + b.location, + b.property_type_id, + b.address1, + b.classification_id, + b.agency_id, + b.administrative_area_id, + b.name, + proj.status_id as project_status_id + FROM building b + LEFT JOIN + (SELECT pp.building_id, + pp.id, + pp.project_id + FROM project_property pp + INNER JOIN + (SELECT building_id, + MAX(updated_on) AS max_updated_on + FROM project_property + GROUP BY building_id) pp_max ON pp.building_id = pp_max.building_id + AND pp.updated_on = pp_max.max_updated_on) pp_recent ON b.id = pp_recent.building_id + LEFT JOIN project proj ON proj.id = pp_recent.project_id + WHERE b.deleted_on IS NULL ) c + LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id; + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'map_properties', + 'SELECT c.id,\n c.pid,\n c.pin,\n c.location,\n c.property_type_id,\n c.address1,\n c.classification_id,\n c.agency_id,\n c.administrative_area_id,\n c.name,\n aa.regional_district_id as regional_district_id,\n c.project_status_id\n FROM\n (SELECT p.id,\n p.pid,\n p.pin,\n p.location,\n p.property_type_id,\n p.address1,\n p.classification_id,\n p.agency_id,\n p.administrative_area_id,\n p.name,\n proj.status_id as project_status_id\n FROM parcel p\n LEFT JOIN\n (SELECT pp.parcel_id,\n pp.id,\n pp.project_id\n FROM project_property pp\n INNER JOIN\n (SELECT parcel_id,\n MAX(updated_on) AS max_updated_on\n FROM project_property\n GROUP BY parcel_id) pp_max ON pp.parcel_id = pp_max.parcel_id\n AND pp.updated_on = pp_max.max_updated_on) pp_recent ON p.id = pp_recent.parcel_id\n LEFT JOIN project proj ON proj.id = pp_recent.project_id\n WHERE p.deleted_on IS NULL\n UNION ALL SELECT b.id,\n b.pid,\n b.pin,\n b.location,\n b.property_type_id,\n b.address1,\n b.classification_id,\n b.agency_id,\n b.administrative_area_id,\n b.name,\n proj.status_id as project_status_id\n FROM building b\n LEFT JOIN\n (SELECT pp.building_id,\n pp.id,\n pp.project_id\n FROM project_property pp\n INNER JOIN\n (SELECT building_id,\n MAX(updated_on) AS max_updated_on\n FROM project_property\n GROUP BY building_id) pp_max ON pp.building_id = pp_max.building_id\n AND pp.updated_on = pp_max.max_updated_on) pp_recent ON b.id = pp_recent.building_id\n LEFT JOIN project proj ON proj.id = pp_recent.project_id\n WHERE b.deleted_on IS NULL ) c\n LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id;', + ], + ); + await queryRunner.query(`CREATE VIEW "property_union" AS WITH property AS (SELECT + 'Parcel' AS property_type, + p.property_type_id, + p.id, + p.classification_id, + p.pid, + p.pin, + p.agency_id, + p.address1, + p.administrative_area_id, + p.is_sensitive, + p.updated_on, + p.land_area, + proj.status_id AS project_status_id +FROM parcel p +LEFT JOIN ( + SELECT + pp.parcel_id, + pp.id, + pp.project_id + FROM + project_property pp + INNER JOIN ( + SELECT + parcel_id, + MAX(updated_on) AS max_updated_on + FROM + project_property + GROUP BY + parcel_id + ) pp_max ON pp.parcel_id = pp_max.parcel_id + AND pp.updated_on = pp_max.max_updated_on +) pp_recent ON p.id = pp_recent.parcel_id +LEFT JOIN project proj ON proj.id = pp_recent.project_id +WHERE p.deleted_on IS NULL +UNION ALL +SELECT + 'Building' AS property_type, + b.property_type_id, + b.id, + b.classification_id, + b.pid, + b.pin, + b.agency_id, + b.address1, + b.administrative_area_id, + b.is_sensitive, + b.updated_on, + NULL AS land_area, + proj.status_id AS project_status_id +FROM building b +LEFT JOIN ( + SELECT + pp.building_id, + pp.id, + pp.project_id + FROM + project_property pp + INNER JOIN ( + SELECT + building_id, + MAX(updated_on) AS max_updated_on + FROM + project_property + GROUP BY + building_id + ) pp_max ON pp.building_id = pp_max.building_id + AND pp.updated_on = pp_max.max_updated_on +) pp_recent ON b.id = pp_recent.building_id +LEFT JOIN project proj ON proj.id = pp_recent.project_id +WHERE b.deleted_on IS NULL) +SELECT + property.*, + agc."name" AS agency_name, + aa."name" AS administrative_area_name, + pc."name" AS property_classification_name, + ps."name" AS project_status_name +FROM property + LEFT JOIN agency agc ON property.agency_id = agc.id + LEFT JOIN administrative_area aa ON property.administrative_area_id = aa.id + LEFT JOIN property_classification pc ON property.classification_id = pc.id + LEFT JOIN project_status ps ON property.project_status_id = ps.id;`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'property_union', + 'WITH property AS (SELECT \n\t\'Parcel\' AS property_type,\n p.property_type_id,\n\tp.id,\n\tp.classification_id,\n\tp.pid,\n\tp.pin,\n\tp.agency_id,\n\tp.address1,\n\tp.administrative_area_id,\n\tp.is_sensitive,\n\tp.updated_on,\n\tp.land_area,\n proj.status_id AS project_status_id\nFROM parcel p\nLEFT JOIN (\n SELECT\n pp.parcel_id,\n pp.id,\n pp.project_id\n FROM\n project_property pp\n INNER JOIN (\n SELECT\n parcel_id,\n MAX(updated_on) AS max_updated_on\n FROM\n project_property\n GROUP BY\n parcel_id\n ) pp_max ON pp.parcel_id = pp_max.parcel_id\n AND pp.updated_on = pp_max.max_updated_on\n) pp_recent ON p.id = pp_recent.parcel_id\nLEFT JOIN project proj ON proj.id = pp_recent.project_id\nWHERE p.deleted_on IS NULL\nUNION ALL\nSELECT \n\t\'Building\' AS property_type,\n b.property_type_id,\n\tb.id,\n\tb.classification_id,\n\tb.pid,\n\tb.pin,\n\tb.agency_id,\n\tb.address1,\n\tb.administrative_area_id,\n\tb.is_sensitive,\n\tb.updated_on,\n\tNULL AS land_area,\n proj.status_id AS project_status_id\nFROM building b\nLEFT JOIN (\n SELECT\n pp.building_id,\n pp.id,\n pp.project_id\n FROM\n project_property pp\n INNER JOIN (\n SELECT\n building_id,\n MAX(updated_on) AS max_updated_on\n FROM\n project_property\n GROUP BY\n building_id\n ) pp_max ON pp.building_id = pp_max.building_id\n AND pp.updated_on = pp_max.max_updated_on\n) pp_recent ON b.id = pp_recent.building_id\nLEFT JOIN project proj ON proj.id = pp_recent.project_id\nWHERE b.deleted_on IS NULL)\nSELECT \n\tproperty.*, \n\tagc."name" AS agency_name,\n\taa."name" AS administrative_area_name,\n\tpc."name" AS property_classification_name,\n ps."name" AS project_status_name\nFROM property \n\tLEFT JOIN agency agc ON property.agency_id = agc.id\n\tLEFT JOIN administrative_area aa ON property.administrative_area_id = aa.id\n\tLEFT JOIN property_classification pc ON property.classification_id = pc.id\n LEFT JOIN project_status ps ON property.project_status_id = ps.id;', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'property_union', 'public'], + ); + await queryRunner.query(`DROP VIEW "property_union"`); + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'map_properties', 'public'], + ); + await queryRunner.query(`DROP VIEW "map_properties"`); + await queryRunner.query(`CREATE VIEW "map_properties" AS SELECT c.id, c.pid, c.pin, c.location, c.property_type_id, c.address1, c.classification_id, c.agency_id, c.administrative_area_id, c.name, aa.regional_district_id as regional_district_id + FROM ( + SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name + FROM parcel WHERE deleted_on IS NULL + UNION ALL + SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name + FROM building WHERE deleted_on IS NULL + ) c + LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id;`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'map_properties', + 'SELECT c.id, c.pid, c.pin, c.location, c.property_type_id, c.address1, c.classification_id, c.agency_id, c.administrative_area_id, c.name, aa.regional_district_id as regional_district_id\n FROM (\n SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name \n FROM parcel WHERE deleted_on IS NULL\n UNION ALL\n SELECT id, pid, pin, location, property_type_id, address1, classification_id, agency_id, administrative_area_id, name \n FROM building WHERE deleted_on IS NULL\n ) c\n LEFT JOIN administrative_area aa ON c.administrative_area_id = aa.id;', + ], + ); + await queryRunner.query(`CREATE VIEW "property_union" AS WITH property AS (SELECT + 'Parcel' AS property_type, + property_type_id, + id, + classification_id, + pid, + pin, + agency_id, + address1, + administrative_area_id, + is_sensitive, + updated_on, + land_area +FROM parcel p +WHERE deleted_on IS NULL +UNION ALL +SELECT + 'Building' AS property_type, + property_type_id, + id, + classification_id, + pid, + pin, + agency_id, + address1, + administrative_area_id, + is_sensitive, + updated_on, + NULL AS land_area +FROM building b +WHERE deleted_on IS NULL) +SELECT + property.*, + agc."name" AS agency_name, + aa."name" AS administrative_area_name, + pc."name" AS property_classification_name +FROM property + LEFT JOIN agency agc ON property.agency_id = agc.id + LEFT JOIN administrative_area aa ON property.administrative_area_id = aa.id + LEFT JOIN property_classification pc ON property.classification_id = pc.id;`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'property_union', + 'WITH property AS (SELECT \n\t\'Parcel\' AS property_type,\n property_type_id,\n\tid,\n\tclassification_id,\n\tpid,\n\tpin,\n\tagency_id,\n\taddress1,\n\tadministrative_area_id,\n\tis_sensitive,\n\tupdated_on,\n\tland_area\nFROM parcel p\nWHERE deleted_on IS NULL\nUNION ALL\nSELECT \n\t\'Building\' AS property_type,\n property_type_id,\n\tid,\n\tclassification_id,\n\tpid,\n\tpin,\n\tagency_id,\n\taddress1,\n\tadministrative_area_id,\n\tis_sensitive,\n\tupdated_on,\n\tNULL AS land_area\nFROM building b\nWHERE deleted_on IS NULL)\nSELECT \n\tproperty.*, \n\tagc."name" AS agency_name,\n\taa."name" AS administrative_area_name,\n\tpc."name" AS property_classification_name\nFROM property \n\tLEFT JOIN agency agc ON property.agency_id = agc.id\n\tLEFT JOIN administrative_area aa ON property.administrative_area_id = aa.id\n\tLEFT JOIN property_classification pc ON property.classification_id = pc.id;', + ], + ); + } +} diff --git a/express-api/tests/testUtils/factories.ts b/express-api/tests/testUtils/factories.ts index 61b40defe3..2c7e4d1ff0 100644 --- a/express-api/tests/testUtils/factories.ts +++ b/express-api/tests/testUtils/factories.ts @@ -605,6 +605,7 @@ export const produceTimestampType = (): TimestampType => { export const produceProject = ( props?: Partial, projectProperties?: ProjectProperty[], + produceRelations?: boolean, ): Project => { const projectId = faker.number.int(); const project: Project = { @@ -632,24 +633,24 @@ export const produceProject = ( CompletedOn: null, ProjectType: 1, AgencyId: 1, - Agency: produceAgency(), + Agency: produceRelations ? produceAgency() : null, TierLevelId: 1, - TierLevel: produceTierLevel(), + TierLevel: produceRelations ? produceTierLevel() : null, StatusId: 1, - Status: produceProjectStatus(), + Status: produceRelations ? produceProjectStatus() : null, RiskId: 1, - Risk: produceRisk(), - Tasks: [produceProjectTask()], + Risk: produceRelations ? produceRisk() : null, + Tasks: produceRelations ? [produceProjectTask()] : [], ProjectProperties: projectProperties ?? [ produceProjectProperty({ ProjectId: projectId, }), ], - Timestamps: [produceProjectTimestamp()], - Monetaries: [produceProjectMonetary()], + Timestamps: produceRelations ? [produceProjectTimestamp()] : [], + Monetaries: produceRelations ? [produceProjectMonetary()] : [], Notifications: [], StatusHistory: [], - Notes: [produceNote()], + Notes: produceRelations ? [produceNote()] : [], AgencyResponses: [], DeletedBy: undefined, DeletedById: null, @@ -758,7 +759,10 @@ export const produceNote = (props?: Partial): ProjectNote => { return note; }; -export const produceProjectProperty = (props?: Partial): ProjectProperty => { +export const produceProjectProperty = ( + props?: Partial, + produceRelations?: boolean, +): ProjectProperty => { const projectProperty: ProjectProperty = { Id: faker.number.int(), CreatedById: randomUUID(), @@ -768,13 +772,13 @@ export const produceProjectProperty = (props?: Partial): Projec UpdatedBy: undefined, UpdatedOn: new Date(), ProjectId: faker.number.int(), - Project: null, + Project: produceRelations ? produceProject() : null, PropertyTypeId: faker.number.int({ min: 0, max: 2 }), - PropertyType: null, + PropertyType: produceRelations ? producePropertyType() : null, ParcelId: faker.number.int(), - Parcel: produceParcel(), + Parcel: produceRelations ? produceParcel() : null, BuildingId: faker.number.int(), - Building: produceBuilding(), + Building: produceRelations ? produceBuilding() : null, DeletedById: null, DeletedOn: null, DeletedBy: undefined, @@ -997,6 +1001,8 @@ export const producePropertyUnion = (props?: Partial) => { AdministrativeAreaId: faker.number.int(), AdministrativeArea: faker.location.city(), LandArea: faker.number.float({ max: 99999 }), + ProjectStatusId: faker.number.int(), + ProjectStatus: faker.company.buzzNoun(), ...props, }; return union; diff --git a/express-api/tests/unit/controllers/buildings/buildingsController.test.ts b/express-api/tests/unit/controllers/buildings/buildingsController.test.ts index 737171e960..16b02a9547 100644 --- a/express-api/tests/unit/controllers/buildings/buildingsController.test.ts +++ b/express-api/tests/unit/controllers/buildings/buildingsController.test.ts @@ -5,10 +5,13 @@ import { MockRes, getRequestHandlerMocks, produceBuilding, + produceProjectProperty, produceUser, } from '../../../testUtils/factories'; import { DeleteResult } from 'typeorm'; import { Roles } from '@/constants/roles'; +import { AppDataSource } from '@/appDataSource'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; const _getBuildingById = jest.fn().mockImplementation(() => produceBuilding()); const _getBuildings = jest.fn().mockImplementation(() => [produceBuilding()]); @@ -32,6 +35,9 @@ jest.mock('@/services/users/usersServices', () => ({ getAgencies: jest.fn().mockResolvedValue([1, 2]), hasAgencies: jest.fn(() => _hasAgencies()), })); +jest + .spyOn(AppDataSource.getRepository(ProjectProperty), 'find') + .mockImplementation(async () => [produceProjectProperty(undefined, true)]); describe('UNIT - Buildings', () => { let mockRequest: Request & MockReq, mockResponse: Response & MockRes; diff --git a/express-api/tests/unit/controllers/parcels/parcelsController.test.ts b/express-api/tests/unit/controllers/parcels/parcelsController.test.ts index 471f933794..3458a12505 100644 --- a/express-api/tests/unit/controllers/parcels/parcelsController.test.ts +++ b/express-api/tests/unit/controllers/parcels/parcelsController.test.ts @@ -5,11 +5,14 @@ import { MockRes, getRequestHandlerMocks, produceParcel, + produceProjectProperty, produceUser, } from '../../../testUtils/factories'; import { DeleteResult } from 'typeorm'; import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode'; import { Roles } from '@/constants/roles'; +import { AppDataSource } from '@/appDataSource'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; const _getParcelById = jest.fn().mockImplementation(() => produceParcel()); const _updateParcel = jest.fn().mockImplementation(() => produceParcel()); @@ -31,6 +34,9 @@ jest.mock('@/services/users/usersServices', () => ({ getAgencies: jest.fn().mockResolvedValue([1, 2]), hasAgencies: jest.fn(() => _hasAgencies()), })); +jest + .spyOn(AppDataSource.getRepository(ProjectProperty), 'find') + .mockImplementation(async () => [produceProjectProperty(undefined, true)]); describe('UNIT - Parcels', () => { let mockRequest: Request & MockReq, mockResponse: Response & MockRes; diff --git a/express-api/tests/unit/controllers/properties/propertiesController.test.ts b/express-api/tests/unit/controllers/properties/propertiesController.test.ts index 37391845ab..96f08e5a5d 100644 --- a/express-api/tests/unit/controllers/properties/propertiesController.test.ts +++ b/express-api/tests/unit/controllers/properties/propertiesController.test.ts @@ -187,6 +187,14 @@ describe('UNIT - Properties', () => { expect(mockResponse.statusValue).toBe(200); expect(Array.isArray(mockResponse.sendValue)).toBe(true); }); + + it('should return status 400 if the filter fails', async () => { + mockRequest.query = { + page: 'bad query', + }; + await getPropertyUnion(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(400); + }); }); describe('POST /properties/import', () => { diff --git a/express-api/tests/unit/services/buildings/buildingService.test.ts b/express-api/tests/unit/services/buildings/buildingService.test.ts index 8ae538e13c..9b9677d9d0 100644 --- a/express-api/tests/unit/services/buildings/buildingService.test.ts +++ b/express-api/tests/unit/services/buildings/buildingService.test.ts @@ -140,6 +140,14 @@ describe('updateBuildingById', () => { ).rejects.toThrow(); }); + it('should throw an error if the building does not belong to the user and user is not admin', async () => { + const generalUser = produceSSO({ client_roles: [Roles.GENERAL_USER] }); + const updateBuilding = produceBuilding(); + expect( + async () => await buildingService.updateBuildingById(updateBuilding, generalUser), + ).rejects.toThrow(); + }); + it('should update Fiscals and Evaluations when they exist in the building object', async () => { const updateBuilding = produceBuilding(); _buildingFindOne.mockResolvedValueOnce(updateBuilding); diff --git a/express-api/tests/unit/services/projects/projectsServices.test.ts b/express-api/tests/unit/services/projects/projectsServices.test.ts index e7f308da4e..5d602501f2 100644 --- a/express-api/tests/unit/services/projects/projectsServices.test.ts +++ b/express-api/tests/unit/services/projects/projectsServices.test.ts @@ -530,7 +530,6 @@ describe('UNIT - Project Services', () => { expect(result.StatusId).toBe(2); expect(result.Name).toBe('New Name'); expect(_projectPropertiesManagerFind).toHaveBeenCalledTimes(5); - //expect(_projectStatusHistoryInsert).toHaveBeenCalledTimes(1); expect(_projectManagerSave).toHaveBeenCalledTimes(1); }); diff --git a/express-api/tests/unit/services/properties/propertyServices.test.ts b/express-api/tests/unit/services/properties/propertyServices.test.ts index 6101eb3125..4b0a97c226 100644 --- a/express-api/tests/unit/services/properties/propertyServices.test.ts +++ b/express-api/tests/unit/services/properties/propertyServices.test.ts @@ -124,6 +124,14 @@ const _projectPropertyCreateQueryBuilder: any = { skip: () => _projectPropertyCreateQueryBuilder, orderBy: () => _projectPropertyCreateQueryBuilder, getMany: () => [produceProjectProperty()], + getRawMany: () => [ + { + project_number: 1, + id: 1, + status_name: 'test', + description: 'test', + }, + ], }; jest @@ -302,6 +310,7 @@ describe('UNIT - Property Services', () => { page: 1, updatedOn: 'after,' + new Date(), quickFilter: 'contains,someWord', + projectStatus: 'is,Cancelled', }); expect(Array.isArray(result.data)).toBe(true); expect(result.data.at(0)).toHaveProperty('PropertyType'); @@ -380,6 +389,19 @@ describe('UNIT - Property Services', () => { expect(result.at(0)).toHaveProperty('PropertyTypeId'); expect(result.at(0)).toHaveProperty('ClassificationId'); }); + + it('should return a list of map property objects, following the UserAgencies filter path', async () => { + const result = await propertyServices.getPropertiesForMap({ + Name: 'some name', + UserAgencies: [1], + AgencyIds: [1], + }); + expect(Array.isArray(result)).toBe(true); + expect(result.at(0)).toHaveProperty('Id'); + expect(result.at(0)).toHaveProperty('Location'); + expect(result.at(0)).toHaveProperty('PropertyTypeId'); + expect(result.at(0)).toHaveProperty('ClassificationId'); + }); }); describe('getImportResults', () => { @@ -693,4 +715,12 @@ describe('UNIT - Property Services', () => { expect(result.BuildingTenancy).toBe(''); }); }); + + describe('findLinkedProjectsForProperty', () => { + it('should return the mapped list of linked projects', async () => { + const result = await propertyServices.findLinkedProjectsForProperty(1); + expect(Array.isArray(result)).toBe(true); + expect((result as unknown as any[])[0].StatusName).toBe('test'); + }); + }); }); diff --git a/react-app/src/components/map/clusterPopup/ClusterPopup.tsx b/react-app/src/components/map/clusterPopup/ClusterPopup.tsx index a028c68da8..5b8030c976 100644 --- a/react-app/src/components/map/clusterPopup/ClusterPopup.tsx +++ b/react-app/src/components/map/clusterPopup/ClusterPopup.tsx @@ -174,6 +174,7 @@ const ClusterPopup = (props: ClusterPopupProps) => { ?.Name ?? 'No Administrative Area', getLookupValueById('Agencies', property.properties.AgencyId)?.Name ?? 'No Agency', ]} + projectStatusId={property.properties.ProjectStatusId} /> ))} diff --git a/react-app/src/components/map/propertyRow/PropertyRow.tsx b/react-app/src/components/map/propertyRow/PropertyRow.tsx index 5ee896b818..b3740e0967 100644 --- a/react-app/src/components/map/propertyRow/PropertyRow.tsx +++ b/react-app/src/components/map/propertyRow/PropertyRow.tsx @@ -1,5 +1,6 @@ import { ClassificationIcon } from '@/components/property/ClassificationIcon'; import { useClassificationStyle } from '@/components/property/PropertyTable'; +import { exposedProjectStatuses } from '@/constants/projectStatuses'; import { PropertyTypes } from '@/constants/propertyTypes'; import { Box, Grid, Typography, useTheme } from '@mui/material'; import React from 'react'; @@ -10,6 +11,7 @@ interface PropertyRowProps { classificationId?: number; title: string; content: string[]; + projectStatusId?: number; } /** @@ -19,7 +21,7 @@ interface PropertyRowProps { * @returns {JSX.Element} The ParcelRow component. */ const PropertyRow = (props: PropertyRowProps) => { - const { id, propertyTypeId, title, content, classificationId } = props; + const { id, propertyTypeId, title, content, classificationId, projectStatusId } = props; const theme = useTheme(); const propertyType = propertyTypeId === PropertyTypes.BUILDING ? 'building' : 'parcel'; @@ -35,22 +37,20 @@ const PropertyRow = (props: PropertyRowProps) => { onClick={() => window.open(`/properties/${propertyType}/${id}`)} sx={{ cursor: 'pointer', - backgroundColor: theme.palette.white.main, + backgroundColor: exposedProjectStatuses.includes(projectStatusId) + ? theme.palette.gold.light + : theme.palette.white.main, + borderBottom: `solid 1px ${theme.palette.gray.main}`, '& :hover': { backgroundColor: theme.palette.gray.main, }, }} > - + { {c} ))} + {exposedProjectStatuses.includes(projectStatusId) ? ( + + In ERP Project + + ) : ( + <> + )} diff --git a/react-app/src/components/map/sidebar/MapSidebar.tsx b/react-app/src/components/map/sidebar/MapSidebar.tsx index ff292498ad..e3c4021ad7 100644 --- a/react-app/src/components/map/sidebar/MapSidebar.tsx +++ b/react-app/src/components/map/sidebar/MapSidebar.tsx @@ -159,6 +159,7 @@ const MapSidebar = (props: MapSidebarProps) => { )?.Name ?? 'No Administrative Area', getLookupValueById('Agencies', property.properties.AgencyId)?.Name ?? 'No Agency', ]} + projectStatusId={property.properties.ProjectStatusId} /> ))} diff --git a/react-app/src/components/projects/ProjectDetail.tsx b/react-app/src/components/projects/ProjectDetail.tsx index 6c9e2e38dd..761f5ceae0 100644 --- a/react-app/src/components/projects/ProjectDetail.tsx +++ b/react-app/src/components/projects/ProjectDetail.tsx @@ -83,9 +83,8 @@ const ProjectDetail = (props: IProjectDetail) => { ); useEffect(() => { - if (data && data.retStatus == 403) { - // TODO: display message with permission error - navigate('/'); // look into maybe using redirect + if (data && data.retStatus !== 200) { + navigate('/'); } }, [data]); diff --git a/react-app/src/components/property/PropertyDetail.tsx b/react-app/src/components/property/PropertyDetail.tsx index def4f8a127..ac0f170fd8 100644 --- a/react-app/src/components/property/PropertyDetail.tsx +++ b/react-app/src/components/property/PropertyDetail.tsx @@ -31,6 +31,7 @@ import { AuthContext } from '@/contexts/authContext'; import { Roles } from '@/constants/roles'; import { LookupContext } from '@/contexts/lookupContext'; import AssociatedProjectsTable from './AssociatedProjectsTable'; +import useUserAgencies from '@/hooks/api/useUserAgencies'; interface IPropertyDetail { onClose: () => void; @@ -95,7 +96,13 @@ const PropertyDetail = (props: IPropertyDetail) => { fetchLinkedProjects(); }, [parcelId, buildingId]); - const isAuditor = keycloak.hasRoles([Roles.AUDITOR]); + const { userAgencies } = useUserAgencies(); + const userAgencyIds = userAgencies.map((a) => a.Id); + + const canEdit = + keycloak.hasRoles([Roles.ADMIN]) || + userAgencyIds.includes(parcel?.parsedBody?.AgencyId) || + userAgencyIds.includes(building?.parsedBody?.AgencyId); const refreshEither = () => { if (parcelId) { @@ -306,7 +313,7 @@ const PropertyDetail = (props: IPropertyDetail) => { > setOpenDeleteDialog(true)} onBackClick={() => props.onClose()} @@ -318,14 +325,14 @@ const PropertyDetail = (props: IPropertyDetail) => { values={mainInformation} title={`${buildingOrParcel} Information`} onEdit={() => setOpenInformationDialog(true)} - disableEdit={isAuditor} + disableEdit={!canEdit} /> setOpenNetBookDialog(true)} - disableEdit={isAuditor} + disableEdit={!canEdit} > @@ -335,7 +342,7 @@ const PropertyDetail = (props: IPropertyDetail) => { values={undefined} title={'Assessed Value'} onEdit={() => setOpenAssessedValueDialog(true)} - disableEdit={isAuditor} + disableEdit={!canEdit} > ; @@ -82,6 +84,14 @@ const PropertyTable = (props: IPropertyTable) => { } }, [lookup.data]); + const projectStatusesForFilter = useMemo(() => { + if (lookup.data) { + return lookup.data.ProjectStatuses.map((a) => a.Name); + } else { + return []; + } + }, [lookup.data]); + const columns: GridColDef[] = [ { field: 'PropertyType', @@ -142,6 +152,14 @@ const PropertyTable = (props: IPropertyTable) => { ); }, }, + { + field: 'ProjectStatus', + headerName: 'Project Status', + type: 'singleSelect', + flex: 1, + maxWidth: 200, + valueOptions: projectStatusesForFilter, + }, { field: 'PID', headerName: 'PID', @@ -226,6 +244,11 @@ const PropertyTable = (props: IPropertyTable) => { items: [{ value, operator: 'is', field: 'Classification' }], }); break; + case 'Approved for ERP': + ref.current.setFilterModel({ + items: [{ value: value, operator: 'is', field: 'ProjectStatus' }], + }); + break; default: ref.current.setFilterModel({ items: [] }); } @@ -369,19 +392,28 @@ const PropertyTable = (props: IPropertyTable) => { Disposed , + Other, + + In ERP Project + , ]} tableHeader={'Properties Overview'} rowCountProp={totalCount} rowCount={totalCount} + getRowClassName={(params) => + exposedProjectStatuses.includes(params.row.ProjectStatusId) ? 'erp-property-row' : '' + } excelTitle={'Properties'} customExcelMap={excelDataMap} columns={columns} addTooltip="Create New Property" - // initialState={{ - // sorting: { - // sortModel: [{ sort: 'desc', field: 'UpdatedOn' }], - // }, - // }} + initialState={{ + columns: { + columnVisibilityModel: { + ProjectStatus: false, + }, + }, + }} /> diff --git a/react-app/src/components/property/propertyRowStyle.css b/react-app/src/components/property/propertyRowStyle.css new file mode 100644 index 0000000000..e5e0c2dca0 --- /dev/null +++ b/react-app/src/components/property/propertyRowStyle.css @@ -0,0 +1,7 @@ +/** + * Used because MUI Data Grid doesn't allow individual styling through props. + * Styles the row in the property table. + */ +.erp-property-row { + background-color: #fae6b94d; +} diff --git a/react-app/src/constants/projectStatuses.ts b/react-app/src/constants/projectStatuses.ts new file mode 100644 index 0000000000..de83391b46 --- /dev/null +++ b/react-app/src/constants/projectStatuses.ts @@ -0,0 +1,40 @@ +/** + * @enum + * Contains past and current project statuses and their IDs. + */ +export enum ProjectStatus { + // DRAFT = 1, // Disabled + // SELECT_PROPERTIES = 2, // Disabled + // UPDATE_INFORMATION = 3, // Disabled + REQUIRED_DOCUMENTATION = 4, + // APPROVAL = 5, // Disabled + // REVIEW = 6, // Disabled + SUBMITTED = 7, + SUBMITTED_EXEMPTION = 8, + // DOCUMENT_REVIEW = 10, // Disabled + // APPRAISAL_REVIEW = 11, // Disabled + // FIRST_NATION_CONSULTATION = 12, // Disabled + // EXEMPTION_REVIEW = 13, // Disabled + APPROVED_FOR_ERP = 14, + APPROVED_FOR_EXEMPTION = 15, + DENIED = 16, + TRANSFERRED_WITHIN_GRE = 20, + APPROVED_FOR_SPL = 21, + NOT_IN_SPL = 22, + CANCELLED = 23, + // IN_ERP = 30, // Disabled + ON_HOLD = 31, + DISPOSED = 32, + PRE_MARKETING = 40, + ON_MARKET = 41, + CONTRACT_IN_PLACE = 42, // Previously CONTRACT_IN_PLACE_CONDITIONAL + // CONTRACT_IN_PLACE_UNCONDITIONAL = 43, // Disabled + CLOSE_OUT = 44, +} + +/** + * Used in parallel with checks in API. + * Projects and properties in ERP are shown to agencies outside of their owning agency. + * Properties in active projects with these status will be highlighted. + */ +export const exposedProjectStatuses = [ProjectStatus.APPROVED_FOR_ERP]; diff --git a/react-app/src/hooks/api/usePropertiesApi.ts b/react-app/src/hooks/api/usePropertiesApi.ts index 33c3469bd1..d42eb11125 100644 --- a/react-app/src/hooks/api/usePropertiesApi.ts +++ b/react-app/src/hooks/api/usePropertiesApi.ts @@ -28,6 +28,7 @@ export interface PropertyGeo { PID?: number; PIN?: number; Address1: string; + ProjectStatusId?: number; }; geometry: { type: 'Point'; diff --git a/react-app/src/themes/appTheme.ts b/react-app/src/themes/appTheme.ts index 747ce3f1df..c29a89034e 100644 --- a/react-app/src/themes/appTheme.ts +++ b/react-app/src/themes/appTheme.ts @@ -42,6 +42,7 @@ const appTheme = createTheme({ palette: { gold: { main: '#FCBA19', + light: '#FAE6B94D', }, blue: { main: '#0e3468',