express-api/src/controllers/properties/propertiesController.ts
import { Request, Response } from 'express';
import propertyServices from '@/services/properties/propertiesServices';
import {
ImportResultFilterSchema,
MapFilter,
MapFilterSchema,
PropertyUnionFilterSchema,
} from '@/controllers/properties/propertiesSchema';
import { checkUserAgencyPermission } from '@/utilities/authorizationChecks';
import userServices from '@/services/users/usersServices';
import { Worker } from 'worker_threads';
import path from 'path';
import fs from 'fs';
import { AppDataSource } from '@/appDataSource';
import { ImportResult } from '@/typeorm/Entities/ImportResult';
import { readFile } from 'xlsx';
import logger from '@/utilities/winstonLogger';
import { Roles } from '@/constants/roles';
/**
* @description Search for a single keyword across multiple different fields in both parcels and buildings.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns {Response} A 200 status with a list of properties.
*/
export const getPropertiesFuzzySearch = async (req: Request, res: Response) => {
const keyword = String(req.query.keyword);
const take = req.query.take ? Number(req.query.take) : undefined;
const user = req.pimsUser;
let userAgencies;
const isAdmin = user.hasOneOfRoles([Roles.ADMIN]);
if (!isAdmin) {
userAgencies = await userServices.getAgencies(user.Username);
}
const result = await propertyServices.propertiesFuzzySearch(keyword, take, userAgencies);
return res.status(200).send(result);
};
/**
* @description Search for a single keyword across multiple different fields in both parcels and buildings.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns {Response} A 200 status with a list of properties.
*/
export const getLinkedProjects = async (req: Request, res: Response) => {
const buildingId = req.query.buildingId
? parseInt(req.query.buildingId as string, 10)
: undefined;
const parcelId = req.query.parcelId ? parseInt(req.query.parcelId as string, 10) : undefined;
const linkedProjects = await propertyServices.findLinkedProjectsForProperty(buildingId, parcelId);
return res.status(200).send(linkedProjects);
};
/**
* @description Used to retrieve all property geolocation information.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns {Response} A 200 status with a list of property geolocation information.
*/
export const getPropertiesForMap = async (req: Request, res: Response) => {
// parse for filter
const filter = MapFilterSchema.safeParse(req.query);
if (filter.success == false) {
return res.status(400).send(filter.error);
}
// Converts comma-separated lists to arrays, see schema
// Must remove empty arrays for TypeORM to work
const filterResult: MapFilter = {
...filter.data,
AgencyIds: filter.data.AgencyIds.length ? filter.data.AgencyIds : undefined,
ClassificationIds: filter.data.ClassificationIds.length
? filter.data.ClassificationIds
: undefined,
AdministrativeAreaIds: filter.data.AdministrativeAreaIds.length
? filter.data.AdministrativeAreaIds
: undefined,
PropertyTypeIds: filter.data.PropertyTypeIds.length ? filter.data.PropertyTypeIds : undefined,
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 permittedRoles = [Roles.ADMIN, Roles.AUDITOR];
// Admins and auditors see all, otherwise...
const user = req.pimsUser;
if (!user.hasOneOfRoles(permittedRoles)) {
const requestedAgencies = filterResult.AgencyIds;
const userHasAgencies = await checkUserAgencyPermission(
user,
requestedAgencies,
permittedRoles,
);
// If not agencies were requested or if the user doesn't have those requested agencies
if (!requestedAgencies || !userHasAgencies) {
// Then only show that user's agencies instead.
const usersAgencies = await userServices.getAgencies(user.Username);
filterResult.UserAgencies = usersAgencies;
}
}
const properties = await propertyServices.getPropertiesForMap(filterResult);
// Convert to GeoJSON format
const mapFeatures = properties.map((property) => ({
type: 'Feature',
properties: { ...property },
geometry: {
type: 'Point',
// Coordinates are backward compared to most places. Needed for Superclusterer.
// Superclusterer expects the exact opposite lat,lng compared to Leaflet
coordinates: [property.Location.x, property.Location.y],
},
}));
return res.status(200).send(mapFeatures);
};
/**
* Receives request to upload file containing property information.
* Starts a Node worker to handle property import and updates the ImportResult table.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns {Response} HTTP response indicating successful (200) or failed submission.
*/
export const importProperties = async (req: Request, res: Response) => {
const filePath = req.file.path;
const fileName = req.file.originalname;
const user = req.pimsUser;
delete user.hasOneOfRoles; // Because Node worker cannot pass functions.
try {
readFile(filePath, { WTF: true }); //With this read option disabled it will throw if unexpected data is present.
} catch (e) {
const rootPath = path.resolve(__dirname + '/../../../uploads');
const realPath = fs.realpathSync(path.resolve(filePath));
if (realPath.startsWith(rootPath)) {
fs.unlinkSync(realPath);
}
return res.status(400).send(e.message);
}
const resultRow = await AppDataSource.getRepository(ImportResult).save({
FileName: fileName,
CompletionPercentage: 0,
CreatedById: user.Id,
CreatedOn: new Date(),
});
const workerPath = `../../services/properties/propertyWorker.${process.env.NODE_ENV === 'production' ? 'js' : 'ts'}`;
const worker = new Worker(path.resolve(__dirname, workerPath), {
workerData: { filePath, resultRowId: resultRow.Id, user, roles: [user.RoleId] },
execArgv: [
'--require',
'ts-node/register',
'--require',
'tsconfig-paths/register',
'--require',
'dotenv/config',
],
});
worker.on('message', (msg) => {
logger.info('Worker thread message --', msg);
});
worker.on('error', (err) => {
logger.error(`Worker errored out: ${err.message}`);
AppDataSource.getRepository(ImportResult).update(
{ Id: resultRow.Id },
{ CompletionPercentage: -1 },
);
});
worker.on('exit', (code) => {
logger.info(`Worker hit exit code ${code}`);
fs.unlink(filePath, (err) => {
if (err) logger.error('Failed to cleanup file from file upload.');
});
});
return res.status(200).send(resultRow);
};
/**
* Retrieves the results of a user's bulk import.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns Response with ImportFilterResult.
*/
export const getImportResults = async (req: Request, res: Response) => {
const user = req.pimsUser;
const filter = ImportResultFilterSchema.safeParse(req.query);
if (filter.success == false) {
return res.status(400).send(filter.error);
}
const results = await propertyServices.getImportResults(filter.data, user);
return res.status(200).send(results);
};
/**
* Retrieves a combination of parcels and buildings.
* Useful for lists or queries that require searching both.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns Response with a list of properties.
*/
export const getPropertyUnion = async (req: Request, res: Response) => {
const forExcelExport = req.query.excelExport === 'true';
const filter = PropertyUnionFilterSchema.safeParse(req.query);
if (filter.success == false) {
return res.status(400).send(filter.error);
}
// Prevent getting back unrelated agencies for general users
const user = req.pimsUser;
const filterResult = filter.data;
if (!user.hasOneOfRoles([Roles.ADMIN, Roles.AUDITOR])) {
// get array of user's agencies
const usersAgencies = await userServices.getAgencies(user.Username);
filterResult.agencyIds = usersAgencies;
}
const properties = forExcelExport
? await propertyServices.getPropertiesForExport(filterResult)
: await propertyServices.getPropertiesUnion(filterResult);
return res.status(200).send(properties);
};