Skip to content

Commit

Permalink
PIMS-2029 ERP Visibility (#2654)
Browse files Browse the repository at this point in the history
  • Loading branch information
dbarkowsky authored Sep 9, 2024
1 parent 132f2b7 commit e2766cd
Show file tree
Hide file tree
Showing 28 changed files with 772 additions and 113 deletions.
6 changes: 6 additions & 0 deletions express-api/src/constants/projectStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
26 changes: 25 additions & 1 deletion express-api/src/controllers/buildings/buildingsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 26 additions & 1 deletion express-api/src/controllers/parcels/parcelsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion express-api/src/controllers/projects/projectsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Request, Response } from 'express';
import propertyServices from '@/services/properties/propertiesServices';
import {
ImportResultFilterSchema,
MapFilter,
MapFilterSchema,
PropertyUnionFilterSchema,
} from '@/controllers/properties/propertiesSchema';
Expand Down Expand Up @@ -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
Expand All @@ -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))) {
Expand All @@ -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;
}
}

Expand Down
2 changes: 2 additions & 0 deletions express-api/src/controllers/properties/propertiesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof MapFilterSchema>;
Expand All @@ -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(),
Expand Down
19 changes: 14 additions & 5 deletions express-api/src/services/projects/projectsServices.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
117 changes: 86 additions & 31 deletions express-api/src/services/properties/propertiesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MapProperties> = {
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;
Expand Down Expand Up @@ -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;
};

Expand All @@ -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
Expand Down
Loading

0 comments on commit e2766cd

Please sign in to comment.