Refactor API building service into multiple files
This commit is contained in:
parent
37bbf41576
commit
79eff831b2
@ -4,7 +4,7 @@ import { ApiUserError } from '../errors/api';
|
|||||||
import { UserError } from '../errors/general';
|
import { UserError } from '../errors/general';
|
||||||
import { parsePositiveIntParam, processParam } from '../parameters';
|
import { parsePositiveIntParam, processParam } from '../parameters';
|
||||||
import asyncController from '../routes/asyncController';
|
import asyncController from '../routes/asyncController';
|
||||||
import * as buildingService from '../services/building';
|
import * as buildingService from '../services/building/base';
|
||||||
import * as userService from '../services/user';
|
import * as userService from '../services/user';
|
||||||
|
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
|
|||||||
|
|
||||||
let updatedBuilding: object;
|
let updatedBuilding: object;
|
||||||
try {
|
try {
|
||||||
updatedBuilding = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
|
updatedBuilding = await buildingService.editBuilding(buildingId, buildingUpdate, userId);
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(error instanceof UserError) {
|
if(error instanceof UserError) {
|
||||||
throw new ApiUserError(error.message, error);
|
throw new ApiUserError(error.message, error);
|
||||||
|
@ -1,446 +0,0 @@
|
|||||||
/**
|
|
||||||
* Building data access
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import { ITask } from 'pg-promise';
|
|
||||||
|
|
||||||
import db from '../../db';
|
|
||||||
import { tileCache } from '../../tiles/rendererDefinition';
|
|
||||||
import { BoundingBox } from '../../tiles/types';
|
|
||||||
import * as buildingDataAccess from '../dataAccess/building';
|
|
||||||
import * as likeDataAccess from '../dataAccess/like';
|
|
||||||
import * as verifyDataAccess from '../dataAccess/verify';
|
|
||||||
import { UserError, DatabaseError } from '../errors/general';
|
|
||||||
|
|
||||||
import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
|
|
||||||
|
|
||||||
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
|
||||||
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
|
||||||
|
|
||||||
const TransactionMode = db.$config.pgp.txMode.TransactionMode;
|
|
||||||
const isolationLevel = db.$config.pgp.txMode.isolationLevel;
|
|
||||||
|
|
||||||
// Create a transaction mode (serializable, read-write):
|
|
||||||
const serializable = new TransactionMode({
|
|
||||||
tiLevel: isolationLevel.serializable,
|
|
||||||
readOnly: false
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getLatestRevisionId() {
|
|
||||||
try {
|
|
||||||
const data = await db.oneOrNone(
|
|
||||||
`SELECT MAX(log_id) from logs`
|
|
||||||
);
|
|
||||||
return data == undefined ? undefined : data.max;
|
|
||||||
} catch(err) {
|
|
||||||
console.error(err);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function queryBuildingsAtPoint(lng: number, lat: number) {
|
|
||||||
try {
|
|
||||||
return await db.manyOrNone(
|
|
||||||
`SELECT b.*
|
|
||||||
FROM buildings as b, geometries as g
|
|
||||||
WHERE
|
|
||||||
b.geometry_id = g.geometry_id
|
|
||||||
AND
|
|
||||||
ST_Intersects(
|
|
||||||
ST_Transform(
|
|
||||||
ST_SetSRID(ST_Point($1, $2), 4326),
|
|
||||||
3857
|
|
||||||
),
|
|
||||||
geometry_geom
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
[lng, lat]
|
|
||||||
);
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryBuildingsByReference(key: string, ref: string) {
|
|
||||||
try {
|
|
||||||
if (key === 'toid') {
|
|
||||||
return await db.manyOrNone(
|
|
||||||
`SELECT
|
|
||||||
*
|
|
||||||
FROM
|
|
||||||
buildings
|
|
||||||
WHERE
|
|
||||||
ref_toid = $1
|
|
||||||
`,
|
|
||||||
[ref]
|
|
||||||
);
|
|
||||||
} else if (key === 'uprn') {
|
|
||||||
return await db.manyOrNone(
|
|
||||||
`SELECT
|
|
||||||
b.*
|
|
||||||
FROM
|
|
||||||
buildings as b, building_properties as p
|
|
||||||
WHERE
|
|
||||||
b.building_id = p.building_id
|
|
||||||
AND
|
|
||||||
p.uprn = $1
|
|
||||||
`,
|
|
||||||
[ref]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return { error: 'Key must be UPRN or TOID' };
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
console.error(err);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrentBuildingDataById(id: number) {
|
|
||||||
return db.one(
|
|
||||||
'SELECT * FROM buildings WHERE building_id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBuildingById(id: number) {
|
|
||||||
try {
|
|
||||||
const building = await getCurrentBuildingDataById(id);
|
|
||||||
|
|
||||||
building.edit_history = await getBuildingEditHistory(id);
|
|
||||||
building.verified = await getBuildingVerifications(building);
|
|
||||||
|
|
||||||
return building;
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBuildingEditHistory(id: number) {
|
|
||||||
try {
|
|
||||||
return await db.manyOrNone(
|
|
||||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp) as revision_timestamp, username
|
|
||||||
FROM logs, users
|
|
||||||
WHERE building_id = $1 AND logs.user_id = users.user_id
|
|
||||||
ORDER BY log_timestamp DESC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBuildingLikeById(buildingId: number, userId: string) {
|
|
||||||
try {
|
|
||||||
const res = await db.oneOrNone(
|
|
||||||
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
|
||||||
[buildingId, userId]
|
|
||||||
);
|
|
||||||
return res && res.like;
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBuildingUPRNsById(id: number) {
|
|
||||||
try {
|
|
||||||
return await db.any(
|
|
||||||
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
|
|
||||||
return await updateBuildingData(buildingId, userId, async () => {
|
|
||||||
const processedBuilding = await processBuildingUpdate(buildingId, building);
|
|
||||||
|
|
||||||
// remove read-only fields from consideration
|
|
||||||
delete processedBuilding.building_id;
|
|
||||||
delete processedBuilding.revision_id;
|
|
||||||
delete processedBuilding.geometry_id;
|
|
||||||
|
|
||||||
// return whitelisted fields to update
|
|
||||||
return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function likeBuilding(buildingId: number, userId: string) {
|
|
||||||
return await updateBuildingData(
|
|
||||||
buildingId,
|
|
||||||
userId,
|
|
||||||
async (t) => {
|
|
||||||
// return total like count after update
|
|
||||||
return {
|
|
||||||
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
(t) => {
|
|
||||||
return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unlikeBuilding(buildingId: number, userId: string) {
|
|
||||||
return await updateBuildingData(
|
|
||||||
buildingId,
|
|
||||||
userId,
|
|
||||||
async (t) => {
|
|
||||||
// return total like count after update
|
|
||||||
return {
|
|
||||||
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async (t) => {
|
|
||||||
return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
|
|
||||||
// get current building attribute values for comparison
|
|
||||||
const building = await getCurrentBuildingDataById(buildingId);
|
|
||||||
// keep track of attributes and values verified
|
|
||||||
const verified = {}
|
|
||||||
|
|
||||||
// loop through attribute => value pairs to mark as verified
|
|
||||||
for (let [key, value] of Object.entries(patch)) {
|
|
||||||
// check key in whitelist
|
|
||||||
if(BUILDING_FIELD_WHITELIST.has(key)) {
|
|
||||||
// check value against current from database - JSON.stringify as hack for "any" data type
|
|
||||||
if (JSON.stringify(value) == JSON.stringify(building[key])) {
|
|
||||||
try {
|
|
||||||
await verifyDataAccess.updateBuildingUserVerifiedAttribute(buildingId, userId, key, building[key]);
|
|
||||||
verified[key] = building[key];
|
|
||||||
} catch (error) {
|
|
||||||
// possible reasons:
|
|
||||||
// - not a building
|
|
||||||
// - not a user
|
|
||||||
// - user already verified this attribute for this building
|
|
||||||
throw new DatabaseError(error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (value === null) {
|
|
||||||
await verifyDataAccess.removeBuildingUserVerifiedAttribute(buildingId, userId, key);
|
|
||||||
} else {
|
|
||||||
// not verifying current value
|
|
||||||
const msg = `Attribute "${key}" with value "${value}" did not match latest saved value "${building[key]}"`;
|
|
||||||
throw new DatabaseError(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// not a valid attribute
|
|
||||||
const msg = `Attribute ${key} not recognised.`;
|
|
||||||
throw new DatabaseError(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserVerifiedAttributes(buildingId: number, userId: string) {
|
|
||||||
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBuildingVerifications(building) {
|
|
||||||
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
|
|
||||||
|
|
||||||
const verified = {};
|
|
||||||
for (const element of BUILDING_FIELD_WHITELIST) {
|
|
||||||
verified[element] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of verifications) {
|
|
||||||
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
|
|
||||||
verified[item.attribute] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verified;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Utility functions ===
|
|
||||||
|
|
||||||
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
|
|
||||||
const subObject = {};
|
|
||||||
for (let [key, value] of Object.entries(obj)) {
|
|
||||||
if(fieldWhitelist.has(key)) {
|
|
||||||
subObject[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return subObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
|
|
||||||
* All db hooks get passed a transaction.
|
|
||||||
* @param buildingId The ID of the building to update
|
|
||||||
* @param userId The ID of the user updating the data
|
|
||||||
* @param getUpdateValue Function returning the set of attribute to update for the building
|
|
||||||
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
|
|
||||||
*/
|
|
||||||
async function updateBuildingData(
|
|
||||||
buildingId: number,
|
|
||||||
userId: string,
|
|
||||||
getUpdateValue: (t: ITask<any>) => Promise<object>,
|
|
||||||
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
|
|
||||||
): Promise<object> {
|
|
||||||
return await db.tx({mode: serializable}, async t => {
|
|
||||||
if (preUpdateDbAction != undefined) {
|
|
||||||
await preUpdateDbAction(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = await getUpdateValue(t);
|
|
||||||
|
|
||||||
const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
|
|
||||||
|
|
||||||
console.log(update);
|
|
||||||
const patches = compare(oldBuilding, update);
|
|
||||||
console.log('Patching', buildingId, patches);
|
|
||||||
const [forward, reverse] = patches;
|
|
||||||
if (Object.keys(forward).length === 0) {
|
|
||||||
throw new UserError('No change provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
|
|
||||||
|
|
||||||
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
|
|
||||||
|
|
||||||
expireBuildingTileCache(buildingId);
|
|
||||||
|
|
||||||
return updatedData;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function privateQueryBuildingBBOX(buildingId: number){
|
|
||||||
return db.one(
|
|
||||||
`SELECT
|
|
||||||
ST_XMin(envelope) as xmin,
|
|
||||||
ST_YMin(envelope) as ymin,
|
|
||||||
ST_XMax(envelope) as xmax,
|
|
||||||
ST_YMax(envelope) as ymax
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
ST_Envelope(g.geometry_geom) as envelope
|
|
||||||
FROM buildings as b, geometries as g
|
|
||||||
WHERE
|
|
||||||
b.geometry_id = g.geometry_id
|
|
||||||
AND
|
|
||||||
b.building_id = $1
|
|
||||||
) as envelope`,
|
|
||||||
[buildingId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expireBuildingTileCache(buildingId: number) {
|
|
||||||
const bbox = await privateQueryBuildingBBOX(buildingId);
|
|
||||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
|
||||||
tileCache.removeAllAtBbox(buildingBbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUILDING_FIELD_WHITELIST = new Set([
|
|
||||||
'ref_osm_id',
|
|
||||||
// 'location_name',
|
|
||||||
'location_number',
|
|
||||||
// 'location_street',
|
|
||||||
// 'location_line_two',
|
|
||||||
// 'location_town',
|
|
||||||
// 'location_postcode',
|
|
||||||
'location_latitude',
|
|
||||||
'location_longitude',
|
|
||||||
'date_year',
|
|
||||||
'date_lower',
|
|
||||||
'date_upper',
|
|
||||||
'date_source',
|
|
||||||
'date_source_detail',
|
|
||||||
'date_link',
|
|
||||||
'facade_year',
|
|
||||||
'facade_upper',
|
|
||||||
'facade_lower',
|
|
||||||
'facade_source',
|
|
||||||
'facade_source_detail',
|
|
||||||
'size_storeys_attic',
|
|
||||||
'size_storeys_core',
|
|
||||||
'size_storeys_basement',
|
|
||||||
'size_height_apex',
|
|
||||||
'size_floor_area_ground',
|
|
||||||
'size_floor_area_total',
|
|
||||||
'size_width_frontage',
|
|
||||||
'construction_core_material',
|
|
||||||
'construction_secondary_materials',
|
|
||||||
'construction_roof_covering',
|
|
||||||
'planning_portal_link',
|
|
||||||
'planning_in_conservation_area',
|
|
||||||
'planning_conservation_area_name',
|
|
||||||
'planning_in_list',
|
|
||||||
'planning_list_id',
|
|
||||||
'planning_list_cat',
|
|
||||||
'planning_list_grade',
|
|
||||||
'planning_heritage_at_risk_id',
|
|
||||||
'planning_world_list_id',
|
|
||||||
'planning_in_glher',
|
|
||||||
'planning_glher_url',
|
|
||||||
'planning_in_apa',
|
|
||||||
'planning_apa_name',
|
|
||||||
'planning_apa_tier',
|
|
||||||
'planning_in_local_list',
|
|
||||||
'planning_local_list_url',
|
|
||||||
'planning_in_historic_area_assessment',
|
|
||||||
'planning_historic_area_assessment_url',
|
|
||||||
'sust_breeam_rating',
|
|
||||||
'sust_dec',
|
|
||||||
// 'sust_aggregate_estimate_epc',
|
|
||||||
'sust_retrofit_date',
|
|
||||||
// 'sust_life_expectancy',
|
|
||||||
'building_attachment_form',
|
|
||||||
'date_change_building_use',
|
|
||||||
|
|
||||||
// 'current_landuse_class',
|
|
||||||
'current_landuse_group',
|
|
||||||
'current_landuse_order'
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare old and new data objects, generate shallow merge patch of changed fields
|
|
||||||
* - forward patch is object with {keys: new_values}
|
|
||||||
* - reverse patch is object with {keys: old_values}
|
|
||||||
*
|
|
||||||
* @param {object} oldObj
|
|
||||||
* @param {object} newObj
|
|
||||||
* @param {Set} whitelist
|
|
||||||
* @returns {[object, object]}
|
|
||||||
*/
|
|
||||||
function compare(oldObj: object, newObj: object): [object, object] {
|
|
||||||
const reverse = {};
|
|
||||||
const forward = {};
|
|
||||||
for (const [key, value] of Object.entries(newObj)) {
|
|
||||||
if (!_.isEqual(oldObj[key], value)) {
|
|
||||||
reverse[key] = oldObj[key];
|
|
||||||
forward[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [forward, reverse];
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
queryBuildingsAtPoint,
|
|
||||||
queryBuildingsByReference,
|
|
||||||
getCurrentBuildingDataById,
|
|
||||||
getBuildingById,
|
|
||||||
getBuildingLikeById,
|
|
||||||
getBuildingEditHistory,
|
|
||||||
getBuildingUPRNsById,
|
|
||||||
saveBuilding,
|
|
||||||
likeBuilding,
|
|
||||||
unlikeBuilding,
|
|
||||||
getLatestRevisionId,
|
|
||||||
verifyBuildingAttributes,
|
|
||||||
getUserVerifiedAttributes
|
|
||||||
};
|
|
51
app/src/api/services/building/base.ts
Normal file
51
app/src/api/services/building/base.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Building data access
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
|
import { pickFields } from '../../../helpers';
|
||||||
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
|
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
|
||||||
|
|
||||||
|
import { BUILDING_FIELD_WHITELIST } from './dataFields';
|
||||||
|
import { getBuildingEditHistory } from './history';
|
||||||
|
import { updateBuildingData } from './save';
|
||||||
|
import { getBuildingVerifications } from './verify';
|
||||||
|
|
||||||
|
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
||||||
|
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
||||||
|
|
||||||
|
export async function getBuildingById(id: number) {
|
||||||
|
try {
|
||||||
|
const building = await buildingDataAccess.getBuildingData(id);
|
||||||
|
|
||||||
|
building.edit_history = await getBuildingEditHistory(id);
|
||||||
|
building.verified = await getBuildingVerifications(building);
|
||||||
|
|
||||||
|
return building;
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
|
||||||
|
return await updateBuildingData(buildingId, userId, async () => {
|
||||||
|
const processedBuilding = await processBuildingUpdate(buildingId, building);
|
||||||
|
|
||||||
|
// remove read-only fields from consideration
|
||||||
|
delete processedBuilding.building_id;
|
||||||
|
delete processedBuilding.revision_id;
|
||||||
|
delete processedBuilding.geometry_id;
|
||||||
|
|
||||||
|
// return whitelisted fields to update
|
||||||
|
return pickFields(processedBuilding, BUILDING_FIELD_WHITELIST);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './history';
|
||||||
|
export * from './like';
|
||||||
|
export * from './query';
|
||||||
|
export * from './uprn';
|
||||||
|
export * from './verify';
|
61
app/src/api/services/building/dataFields.ts
Normal file
61
app/src/api/services/building/dataFields.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export const BUILDING_FIELD_WHITELIST = new Set([
|
||||||
|
'ref_osm_id',
|
||||||
|
// 'location_name',
|
||||||
|
'location_number',
|
||||||
|
// 'location_street',
|
||||||
|
// 'location_line_two',
|
||||||
|
// 'location_town',
|
||||||
|
// 'location_postcode',
|
||||||
|
'location_latitude',
|
||||||
|
'location_longitude',
|
||||||
|
'date_year',
|
||||||
|
'date_lower',
|
||||||
|
'date_upper',
|
||||||
|
'date_source',
|
||||||
|
'date_source_detail',
|
||||||
|
'date_link',
|
||||||
|
'facade_year',
|
||||||
|
'facade_upper',
|
||||||
|
'facade_lower',
|
||||||
|
'facade_source',
|
||||||
|
'facade_source_detail',
|
||||||
|
'size_storeys_attic',
|
||||||
|
'size_storeys_core',
|
||||||
|
'size_storeys_basement',
|
||||||
|
'size_height_apex',
|
||||||
|
'size_floor_area_ground',
|
||||||
|
'size_floor_area_total',
|
||||||
|
'size_width_frontage',
|
||||||
|
'construction_core_material',
|
||||||
|
'construction_secondary_materials',
|
||||||
|
'construction_roof_covering',
|
||||||
|
'planning_portal_link',
|
||||||
|
'planning_in_conservation_area',
|
||||||
|
'planning_conservation_area_name',
|
||||||
|
'planning_in_list',
|
||||||
|
'planning_list_id',
|
||||||
|
'planning_list_cat',
|
||||||
|
'planning_list_grade',
|
||||||
|
'planning_heritage_at_risk_id',
|
||||||
|
'planning_world_list_id',
|
||||||
|
'planning_in_glher',
|
||||||
|
'planning_glher_url',
|
||||||
|
'planning_in_apa',
|
||||||
|
'planning_apa_name',
|
||||||
|
'planning_apa_tier',
|
||||||
|
'planning_in_local_list',
|
||||||
|
'planning_local_list_url',
|
||||||
|
'planning_in_historic_area_assessment',
|
||||||
|
'planning_historic_area_assessment_url',
|
||||||
|
'sust_breeam_rating',
|
||||||
|
'sust_dec',
|
||||||
|
// 'sust_aggregate_estimate_epc',
|
||||||
|
'sust_retrofit_date',
|
||||||
|
// 'sust_life_expectancy',
|
||||||
|
'building_attachment_form',
|
||||||
|
'date_change_building_use',
|
||||||
|
|
||||||
|
// 'current_landuse_class',
|
||||||
|
'current_landuse_group',
|
||||||
|
'current_landuse_order'
|
||||||
|
]);
|
28
app/src/api/services/building/history.ts
Normal file
28
app/src/api/services/building/history.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import db from '../../../db';
|
||||||
|
|
||||||
|
export async function getLatestRevisionId() {
|
||||||
|
try {
|
||||||
|
const data = await db.oneOrNone(
|
||||||
|
`SELECT MAX(log_id) from logs`
|
||||||
|
);
|
||||||
|
return data == undefined ? undefined : data.max;
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBuildingEditHistory(id: number) {
|
||||||
|
try {
|
||||||
|
return await db.manyOrNone(
|
||||||
|
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp) as revision_timestamp, username
|
||||||
|
FROM logs, users
|
||||||
|
WHERE building_id = $1 AND logs.user_id = users.user_id
|
||||||
|
ORDER BY log_timestamp DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
49
app/src/api/services/building/like.ts
Normal file
49
app/src/api/services/building/like.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import db from '../../../db';
|
||||||
|
import * as likeDataAccess from '../../dataAccess/like';
|
||||||
|
|
||||||
|
import { updateBuildingData } from './save';
|
||||||
|
|
||||||
|
export async function getBuildingLikeById(buildingId: number, userId: string) {
|
||||||
|
try {
|
||||||
|
const res = await db.oneOrNone(
|
||||||
|
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
return res && res.like;
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function likeBuilding(buildingId: number, userId: string) {
|
||||||
|
return await updateBuildingData(
|
||||||
|
buildingId,
|
||||||
|
userId,
|
||||||
|
async (t) => {
|
||||||
|
// return total like count after update
|
||||||
|
return {
|
||||||
|
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(t) => {
|
||||||
|
return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikeBuilding(buildingId: number, userId: string) {
|
||||||
|
return await updateBuildingData(
|
||||||
|
buildingId,
|
||||||
|
userId,
|
||||||
|
async (t) => {
|
||||||
|
// return total like count after update
|
||||||
|
return {
|
||||||
|
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async (t) => {
|
||||||
|
return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
60
app/src/api/services/building/query.ts
Normal file
60
app/src/api/services/building/query.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import db from '../../../db';
|
||||||
|
|
||||||
|
export async function queryBuildingsAtPoint(lng: number, lat: number) {
|
||||||
|
try {
|
||||||
|
return await db.manyOrNone(
|
||||||
|
`SELECT b.*
|
||||||
|
FROM buildings as b, geometries as g
|
||||||
|
WHERE
|
||||||
|
b.geometry_id = g.geometry_id
|
||||||
|
AND
|
||||||
|
ST_Intersects(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(ST_Point($1, $2), 4326),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
geometry_geom
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[lng, lat]
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryBuildingsByReference(key: string, ref: string) {
|
||||||
|
try {
|
||||||
|
if (key === 'toid') {
|
||||||
|
return await db.manyOrNone(
|
||||||
|
`SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
buildings
|
||||||
|
WHERE
|
||||||
|
ref_toid = $1
|
||||||
|
`,
|
||||||
|
[ref]
|
||||||
|
);
|
||||||
|
} else if (key === 'uprn') {
|
||||||
|
return await db.manyOrNone(
|
||||||
|
`SELECT
|
||||||
|
b.*
|
||||||
|
FROM
|
||||||
|
buildings as b, building_properties as p
|
||||||
|
WHERE
|
||||||
|
b.building_id = p.building_id
|
||||||
|
AND
|
||||||
|
p.uprn = $1
|
||||||
|
`,
|
||||||
|
[ref]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return { error: 'Key must be UPRN or TOID' };
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
81
app/src/api/services/building/save.ts
Normal file
81
app/src/api/services/building/save.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
|
import db from '../../../db';
|
||||||
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
|
import { UserError } from '../../errors/general';
|
||||||
|
|
||||||
|
import { expireBuildingTileCache } from './tileCache';
|
||||||
|
|
||||||
|
|
||||||
|
const TransactionMode = db.$config.pgp.txMode.TransactionMode;
|
||||||
|
const isolationLevel = db.$config.pgp.txMode.isolationLevel;
|
||||||
|
|
||||||
|
// Create a transaction mode (serializable, read-write):
|
||||||
|
const serializable = new TransactionMode({
|
||||||
|
tiLevel: isolationLevel.serializable,
|
||||||
|
readOnly: false
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
|
||||||
|
* All db hooks get passed a transaction.
|
||||||
|
* @param buildingId The ID of the building to update
|
||||||
|
* @param userId The ID of the user updating the data
|
||||||
|
* @param getUpdateValue Function returning the set of attribute to update for the building
|
||||||
|
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
|
||||||
|
*/
|
||||||
|
export async function updateBuildingData(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
getUpdateValue: (t: ITask<any>) => Promise<object>,
|
||||||
|
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
|
||||||
|
): Promise<object> {
|
||||||
|
return await db.tx({mode: serializable}, async t => {
|
||||||
|
if (preUpdateDbAction != undefined) {
|
||||||
|
await preUpdateDbAction(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = await getUpdateValue(t);
|
||||||
|
|
||||||
|
const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
|
||||||
|
|
||||||
|
console.log(update);
|
||||||
|
const patches = compare(oldBuilding, update);
|
||||||
|
console.log('Patching', buildingId, patches);
|
||||||
|
const [forward, reverse] = patches;
|
||||||
|
if (Object.keys(forward).length === 0) {
|
||||||
|
throw new UserError('No change provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
|
||||||
|
|
||||||
|
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
|
||||||
|
|
||||||
|
expireBuildingTileCache(buildingId);
|
||||||
|
|
||||||
|
return updatedData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare old and new data objects, generate shallow merge patch of changed fields
|
||||||
|
* - forward patch is object with {keys: new_values}
|
||||||
|
* - reverse patch is object with {keys: old_values}
|
||||||
|
*
|
||||||
|
* @param {object} oldObj
|
||||||
|
* @param {object} newObj
|
||||||
|
* @param {Set} whitelist
|
||||||
|
* @returns {[object, object]}
|
||||||
|
*/
|
||||||
|
function compare(oldObj: object, newObj: object): [object, object] {
|
||||||
|
const reverse = {};
|
||||||
|
const forward = {};
|
||||||
|
for (const [key, value] of Object.entries(newObj)) {
|
||||||
|
if (!_.isEqual(oldObj[key], value)) {
|
||||||
|
reverse[key] = oldObj[key];
|
||||||
|
forward[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [forward, reverse];
|
||||||
|
}
|
29
app/src/api/services/building/tileCache.ts
Normal file
29
app/src/api/services/building/tileCache.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import db from '../../../db';
|
||||||
|
import { tileCache } from '../../../tiles/rendererDefinition';
|
||||||
|
import { BoundingBox } from '../../../tiles/types';
|
||||||
|
|
||||||
|
function privateQueryBuildingBBOX(buildingId: number){
|
||||||
|
return db.one(
|
||||||
|
`SELECT
|
||||||
|
ST_XMin(envelope) as xmin,
|
||||||
|
ST_YMin(envelope) as ymin,
|
||||||
|
ST_XMax(envelope) as xmax,
|
||||||
|
ST_YMax(envelope) as ymax
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
ST_Envelope(g.geometry_geom) as envelope
|
||||||
|
FROM buildings as b, geometries as g
|
||||||
|
WHERE
|
||||||
|
b.geometry_id = g.geometry_id
|
||||||
|
AND
|
||||||
|
b.building_id = $1
|
||||||
|
) as envelope`,
|
||||||
|
[buildingId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireBuildingTileCache(buildingId: number) {
|
||||||
|
const bbox = await privateQueryBuildingBBOX(buildingId);
|
||||||
|
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||||
|
tileCache.removeAllAtBbox(buildingBbox);
|
||||||
|
}
|
13
app/src/api/services/building/uprn.ts
Normal file
13
app/src/api/services/building/uprn.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import db from '../../../db';
|
||||||
|
|
||||||
|
export async function getBuildingUPRNsById(id: number) {
|
||||||
|
try {
|
||||||
|
return await db.any(
|
||||||
|
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
65
app/src/api/services/building/verify.ts
Normal file
65
app/src/api/services/building/verify.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
|
import * as verifyDataAccess from '../../dataAccess/verify';
|
||||||
|
import { DatabaseError } from '../../errors/general';
|
||||||
|
|
||||||
|
import { BUILDING_FIELD_WHITELIST } from './dataFields';
|
||||||
|
|
||||||
|
export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
|
||||||
|
// get current building attribute values for comparison
|
||||||
|
const building = await buildingDataAccess.getBuildingData(buildingId);
|
||||||
|
// keep track of attributes and values verified
|
||||||
|
const verified = {}
|
||||||
|
|
||||||
|
// loop through attribute => value pairs to mark as verified
|
||||||
|
for (let [key, value] of Object.entries(patch)) {
|
||||||
|
// check key in whitelist
|
||||||
|
if(BUILDING_FIELD_WHITELIST.has(key)) {
|
||||||
|
// check value against current from database - JSON.stringify as hack for "any" data type
|
||||||
|
if (JSON.stringify(value) == JSON.stringify(building[key])) {
|
||||||
|
try {
|
||||||
|
await verifyDataAccess.updateBuildingUserVerifiedAttribute(buildingId, userId, key, building[key]);
|
||||||
|
verified[key] = building[key];
|
||||||
|
} catch (error) {
|
||||||
|
// possible reasons:
|
||||||
|
// - not a building
|
||||||
|
// - not a user
|
||||||
|
// - user already verified this attribute for this building
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value === null) {
|
||||||
|
await verifyDataAccess.removeBuildingUserVerifiedAttribute(buildingId, userId, key);
|
||||||
|
} else {
|
||||||
|
// not verifying current value
|
||||||
|
const msg = `Attribute "${key}" with value "${value}" did not match latest saved value "${building[key]}"`;
|
||||||
|
throw new DatabaseError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// not a valid attribute
|
||||||
|
const msg = `Attribute ${key} not recognised.`;
|
||||||
|
throw new DatabaseError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserVerifiedAttributes(buildingId: number, userId: string) {
|
||||||
|
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBuildingVerifications(building) {
|
||||||
|
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
|
||||||
|
|
||||||
|
const verified = {};
|
||||||
|
for (const element of BUILDING_FIELD_WHITELIST) {
|
||||||
|
verified[element] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of verifications) {
|
||||||
|
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
|
||||||
|
verified[item.attribute] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verified;
|
||||||
|
}
|
@ -1,21 +1,13 @@
|
|||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import { hasAnyOwnProperty } from '../../../helpers';
|
import { hasAnyOwnProperty } from '../../../helpers';
|
||||||
|
import { getBuildingData } from '../../dataAccess/building';
|
||||||
import { ArgumentError } from '../../errors/general';
|
import { ArgumentError } from '../../errors/general';
|
||||||
import { getCurrentBuildingDataById } from '../building';
|
|
||||||
|
|
||||||
import { updateLandUse } from './landUse';
|
import { updateLandUse } from './landUse';
|
||||||
|
|
||||||
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
|
|
||||||
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
|
|
||||||
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildingUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
|
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
|
||||||
const currentBuildingData = await getCurrentBuildingDataById(buildingId);
|
const currentBuildingData = await getBuildingData(buildingId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentLandUseUpdate = await updateLandUse(
|
const currentLandUseUpdate = await updateLandUse(
|
||||||
@ -38,3 +30,11 @@ async function processCurrentLandUseClassifications(buildingId: number, building
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
|
||||||
|
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
|
||||||
|
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildingUpdate;
|
||||||
|
}
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
getBuildingUPRNsById,
|
getBuildingUPRNsById,
|
||||||
getLatestRevisionId,
|
getLatestRevisionId,
|
||||||
getUserVerifiedAttributes
|
getUserVerifiedAttributes
|
||||||
} from './api/services/building';
|
} from './api/services/building/base';
|
||||||
import { getUserById } from './api/services/user';
|
import { getUserById } from './api/services/user';
|
||||||
import App from './frontend/app';
|
import App from './frontend/app';
|
||||||
import { parseBuildingURL } from './parse';
|
import { parseBuildingURL } from './parse';
|
||||||
|
@ -58,3 +58,14 @@ export function incBigInt(bigStr: string): string {
|
|||||||
export function decBigInt(bigStr: string): string {
|
export function decBigInt(bigStr: string): string {
|
||||||
return bigStr == undefined ? bigStr : String(BigInt(bigStr) - BigInt(1));
|
return bigStr == undefined ? bigStr : String(BigInt(bigStr) - BigInt(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function pickFields(obj: any, fieldWhitelist: Set<string>) {
|
||||||
|
const subObject = {};
|
||||||
|
for (let [key, value] of Object.entries(obj)) {
|
||||||
|
if(fieldWhitelist.has(key)) {
|
||||||
|
subObject[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subObject;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user