Allow for more API data configuration

This puts the data configuration in a dedicated file,
and allows for separately allowing/disallowing editing and verification
for each field, as well as specifying PSQL types to cast to on inserting
This commit is contained in:
Maciej Ziarkowski 2021-03-11 18:33:04 +00:00
parent 4bd3c147e3
commit 58bc11be04
6 changed files with 288 additions and 67 deletions

View File

@ -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<DataFieldConfig>()({ /* 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,
},
});

View File

@ -2,6 +2,7 @@
import { errors, ITask } from 'pg-promise'; import { errors, ITask } from 'pg-promise';
import db from '../../db'; import db from '../../db';
import { dataFieldsConfig } from '../config/dataFields';
import { ArgumentError, DatabaseError } from '../errors/general'; import { ArgumentError, DatabaseError } from '../errors/general';
export async function getBuildingData( 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( export async function updateBuildingData(
buildingId: number, buildingId: number,
forwardPatch: object, forwardPatch: object,
revisionId: string, revisionId: string,
t?: ITask<any> t?: ITask<any>
): Promise<object> { ): Promise<object> {
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); console.log('Setting', buildingId, sets);

View File

@ -5,10 +5,10 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import { pickFields } from '../../../helpers'; import { pickFields } from '../../../helpers';
import { dataFieldsConfig } from '../../config/dataFields';
import * as buildingDataAccess from '../../dataAccess/building'; import * as buildingDataAccess from '../../dataAccess/building';
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate'; import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
import { BUILDING_FIELD_WHITELIST } from './dataFields';
import { getBuildingEditHistory } from './history'; import { getBuildingEditHistory } from './history';
import { updateBuildingData } from './save'; import { updateBuildingData } from './save';
import { getBuildingVerifications } from './verify'; 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<object> { // TODO add proper building type export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
return await updateBuildingData(buildingId, userId, async () => { return await updateBuildingData(buildingId, userId, async () => {
const processedBuilding = await processBuildingUpdate(buildingId, building); const processedBuilding = await processBuildingUpdate(buildingId, building);
@ -40,7 +42,7 @@ export async function editBuilding(buildingId: number, building: any, userId: st
delete processedBuilding.geometry_id; delete processedBuilding.geometry_id;
// return whitelisted fields to update // return whitelisted fields to update
return pickFields(processedBuilding, BUILDING_FIELD_WHITELIST); return pickFields(processedBuilding, FIELD_EDIT_WHITELIST);
}); });
} }

View File

@ -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'
]);

View File

@ -1,8 +1,9 @@
import { dataFieldsConfig } from '../../config/dataFields';
import * as buildingDataAccess from '../../dataAccess/building'; import * as buildingDataAccess from '../../dataAccess/building';
import * as verifyDataAccess from '../../dataAccess/verify'; import * as verifyDataAccess from '../../dataAccess/verify';
import { DatabaseError } from '../../errors/general'; 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) { export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
// get current building attribute values for comparison // 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 // loop through attribute => value pairs to mark as verified
for (let [key, value] of Object.entries(patch)) { for (let [key, value] of Object.entries(patch)) {
// check key in whitelist // 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 // check value against current from database - JSON.stringify as hack for "any" data type
if (JSON.stringify(value) == JSON.stringify(building[key])) { if (JSON.stringify(value) == JSON.stringify(building[key])) {
try { try {
@ -52,7 +53,7 @@ export async function getBuildingVerifications(building) {
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id); const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
const verified = {}; const verified = {};
for (const element of BUILDING_FIELD_WHITELIST) { for (const element of FIELD_VERIFICATION_WHITELIST) {
verified[element] = 0; verified[element] = 0;
} }

View File

@ -74,3 +74,26 @@ export function pickFields(obj: any, fieldWhitelist: Set<string>) {
} }
return subObject; 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<C> = <K extends string>(x: Record<K, C>) => Record<K, C>;
/**
* 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<C>(): ValueTypeCheck<C>{
return x => x;
}
/**
* Map all properties of object through a function
*/
export function mapObject<T, R>(x: T, fn: ([key, value]: [keyof T, T[keyof T]]) => R): Record<keyof T, R> {
return Object.assign({}, ...Object.entries(x).map(([key, value]) => ({ [key]: fn([key as keyof T, value]) })) );
}