diff --git a/app/src/api/config/dataFields.ts b/app/src/api/config/dataFields.ts new file mode 100644 index 00000000..e7ce695a --- /dev/null +++ b/app/src/api/config/dataFields.ts @@ -0,0 +1,242 @@ +import { valueType } from '../../helpers'; + +/** Configuration for a single data field */ +export interface DataFieldConfig { + /** + * Allow editing the field? + */ + edit: boolean; + + /** + * Allow verifying the field value? + * Default: false; + */ + verify?: boolean; + + /** + * Should the update value be formatted as JSON text? + * E.g. if the database column is of type json, the object coming from + * the HTTP request body needs to be formatted as json by pg-promise + * See more on formatting here https://vitaly-t.github.io/pg-promise/formatting.html#.format + * Default: false + */ + asJson?: boolean; + + /** + * Should the formatted value be cast to a different type when inserting to db? + * Useful when the JS object from request needs to be mapped to a Postgres-specific column + * E.g. can map a complex object to jsonb (need to format it as text json, too, with the asJson option above) + * (Add more options here as they become necessary) + * Default: undefined + */ + sqlCast?: 'json' | 'jsonb'; +} + +export const dataFieldsConfig = valueType()({ /* eslint-disable @typescript-eslint/camelcase */ + ref_osm_id: { + edit: true, + }, + location_name: { + edit: false, + verify: true, + }, + location_number: { + edit: true, + verify: true, + }, + location_street: { + edit: false, + verify: true, + }, + location_line_two: { + edit: false, + }, + location_town: { + edit: false, + }, + location_postcode: { + edit: false, + }, + location_latitude: { + edit: true, + }, + location_longitude: { + edit: true, + }, + date_year: { + edit: true, + verify: true, + }, + date_lower: { + edit: true, + }, + date_upper: { + edit: true, + }, + date_source: { + edit: true, + }, + date_source_detail: { + edit: true, + }, + date_link: { + edit: true, + }, + facade_year: { + edit: true, + }, + facade_upper: { + edit: false, + }, + facade_lower: { + edit: false, + }, + facade_source: { + edit: false, + }, + facade_source_detail: { + edit: false, + }, + size_storeys_attic: { + edit: true, + verify: true, + }, + size_storeys_core: { + edit: true, + verify: true, + }, + size_storeys_basement: { + edit: true, + verify: true, + }, + size_height_apex: { + edit: true, + verify: true, + }, + size_floor_area_ground: { + edit: true, + verify: true, + }, + size_floor_area_total: { + edit: true, + verify: true, + }, + size_width_frontage: { + edit: true, + verify: true, + }, + construction_core_material: { + edit: true, + }, + construction_secondary_materials: { + edit: false, + }, + construction_roof_covering: { + edit: true, + }, + planning_portal_link: { + edit: true, + verify: true, + }, + planning_in_conservation_area: { + edit: true, + verify: true, + }, + planning_conservation_area_name: { + edit: true, + verify: true, + }, + planning_in_list: { + edit: false, + }, + planning_list_id: { + edit: false, + }, + planning_list_cat: { + edit: false, + }, + planning_list_grade: { + edit: false, + }, + planning_heritage_at_risk_id: { + edit: true, + verify: true, + }, + planning_world_list_id: { + edit: true, + verify: true, + }, + planning_in_glher: { + edit: true, + verify: true, + }, + planning_glher_url: { + edit: true, + verify: true, + }, + planning_in_apa: { + edit: true, + verify: true, + }, + planning_apa_name: { + edit: true, + verify: true, + }, + planning_apa_tier: { + edit: true, + verify: true, + }, + planning_in_local_list: { + edit: true, + verify: true, + }, + planning_local_list_url: { + edit: true, + verify: true, + }, + planning_in_historic_area_assessment: { + edit: true, + verify: true, + }, + planning_historic_area_assessment_url: { + edit: true, + verify: true, + }, + sust_breeam_rating: { + edit: true, + verify: true, + }, + sust_dec: { + edit: true, + verify: true, + }, + sust_aggregate_estimate_epc: { + edit: false, + }, + sust_retrofit_date: { + edit: true, + verify: true, + }, + sust_life_expectancy: { + edit: false, + }, + building_attachment_form: { + edit: true, + verify: true, + }, + date_change_building_use: { + edit: true, + }, + current_landuse_class: { + edit: false, + }, + current_landuse_group: { + edit: true, + verify: true, + }, + current_landuse_order: { + edit: false, + verify: false, + }, + +}); diff --git a/app/src/api/dataAccess/building.ts b/app/src/api/dataAccess/building.ts index 57dbb0f4..9763df63 100644 --- a/app/src/api/dataAccess/building.ts +++ b/app/src/api/dataAccess/building.ts @@ -2,6 +2,7 @@ import { errors, ITask } from 'pg-promise'; import db from '../../db'; +import { dataFieldsConfig } from '../config/dataFields'; import { ArgumentError, DatabaseError } from '../errors/general'; export async function getBuildingData( @@ -52,13 +53,26 @@ export async function insertEditHistoryRevision( } } +const columnConfigLookup = Object.assign( + {}, + ...Object.entries(dataFieldsConfig).filter(([, config]) => config.edit).map(([key, { + asJson = false, + sqlCast + }]) => ({ [key]: { + name: key, + mod: asJson ? ':json' : undefined, + cast: sqlCast + } })) +); + export async function updateBuildingData( buildingId: number, forwardPatch: object, revisionId: string, t?: ITask ): Promise { - const sets = db.$config.pgp.helpers.sets(forwardPatch); + const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]); + const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig); console.log('Setting', buildingId, sets); diff --git a/app/src/api/services/building/base.ts b/app/src/api/services/building/base.ts index e155e850..d5c636c4 100644 --- a/app/src/api/services/building/base.ts +++ b/app/src/api/services/building/base.ts @@ -5,10 +5,10 @@ import * as _ from 'lodash'; import { pickFields } from '../../../helpers'; +import { dataFieldsConfig } from '../../config/dataFields'; 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'; @@ -30,6 +30,8 @@ export async function getBuildingById(id: number) { } } +const FIELD_EDIT_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit).map(([key]) => key)); + export async function editBuilding(buildingId: number, building: any, userId: string): Promise { // TODO add proper building type return await updateBuildingData(buildingId, userId, async () => { const processedBuilding = await processBuildingUpdate(buildingId, building); @@ -40,7 +42,7 @@ export async function editBuilding(buildingId: number, building: any, userId: st delete processedBuilding.geometry_id; // return whitelisted fields to update - return pickFields(processedBuilding, BUILDING_FIELD_WHITELIST); + return pickFields(processedBuilding, FIELD_EDIT_WHITELIST); }); } diff --git a/app/src/api/services/building/dataFields.ts b/app/src/api/services/building/dataFields.ts deleted file mode 100644 index 9bc4cd84..00000000 --- a/app/src/api/services/building/dataFields.ts +++ /dev/null @@ -1,61 +0,0 @@ -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' -]); diff --git a/app/src/api/services/building/verify.ts b/app/src/api/services/building/verify.ts index 1f16aa52..8baae9e1 100644 --- a/app/src/api/services/building/verify.ts +++ b/app/src/api/services/building/verify.ts @@ -1,8 +1,9 @@ +import { dataFieldsConfig } from '../../config/dataFields'; import * as buildingDataAccess from '../../dataAccess/building'; import * as verifyDataAccess from '../../dataAccess/verify'; import { DatabaseError } from '../../errors/general'; -import { BUILDING_FIELD_WHITELIST } from './dataFields'; +const FIELD_VERIFICATION_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.verify === true).map(([key]) => key)); export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) { // get current building attribute values for comparison @@ -13,7 +14,7 @@ export async function verifyBuildingAttributes(buildingId: number, userId: strin // 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)) { + if(FIELD_VERIFICATION_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 { @@ -52,7 +53,7 @@ export async function getBuildingVerifications(building) { const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id); const verified = {}; - for (const element of BUILDING_FIELD_WHITELIST) { + for (const element of FIELD_VERIFICATION_WHITELIST) { verified[element] = 0; } diff --git a/app/src/helpers.ts b/app/src/helpers.ts index 0c7b2057..471f3fee 100644 --- a/app/src/helpers.ts +++ b/app/src/helpers.ts @@ -74,3 +74,26 @@ export function pickFields(obj: any, fieldWhitelist: Set) { } return subObject; } + +/** + * Generic type for a function validating that the argument is a object with + * Used to enforce value types in a config object, but not obscuring the key names + * by using a TS lookup type + */ +type ValueTypeCheck = (x: Record) => Record; + +/** + * Creates a function that enforces all fields of its argument to be of type C + * Useful to create configs where each field must be of a set type, + * but the list of keys should be accessible to users of the config variable. + */ +export function valueType(): ValueTypeCheck{ + return x => x; +} + +/** + * Map all properties of object through a function + */ +export function mapObject(x: T, fn: ([key, value]: [keyof T, T[keyof T]]) => R): Record { + return Object.assign({}, ...Object.entries(x).map(([key, value]) => ({ [key]: fn([key as keyof T, value]) })) ); +} \ No newline at end of file