diff --git a/app/package-lock.json b/app/package-lock.json index 7aacc192..5bac4f93 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -2031,6 +2031,12 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/pg-format": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/pg-format/-/pg-format-1.0.2.tgz", + "integrity": "sha512-D3MEO6u3BObw3G4Xewjdx05MF5v/fiog78CedtrXe8BhONM8GvUz2dPfLWtI0BPRBoRd6anPHXe+sbrPReZouQ==", + "dev": true + }, "@types/prettier": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.3.tgz", @@ -11607,9 +11613,9 @@ } }, "node-abi": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.21.0.tgz", - "integrity": "sha512-smhrivuPqEM3H5LmnY3KU6HfYv0u4QklgAxfFyRNujKUzbUcYZ+Jc2EhukB9SRcD2VpqhxM7n/MIcp1Ua1/JMg==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.0.tgz", + "integrity": "sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg==", "requires": { "semver": "^5.4.1" } @@ -11695,11 +11701,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz", "integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ==" }, - "noop-logger": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", - "integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI=" - }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -12561,6 +12562,11 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" }, + "pg-format": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz", + "integrity": "sha1-J3NCNsKtP05QZJFaWTNOIAQKgo4=" + }, "pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -14222,9 +14228,9 @@ } }, "prebuild-install": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.1.tgz", - "integrity": "sha512-M+cKwofFlHa5VpTWub7GLg5RLcunYIcLqtY5pKcls/u7xaAb8FrXZ520qY8rkpYy5xw90tYCyMO0MP5ggzR3Sw==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.3.tgz", + "integrity": "sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q==", "requires": { "detect-libc": "^1.0.3", "expand-template": "^2.0.3", @@ -14233,7 +14239,6 @@ "mkdirp-classic": "^0.5.3", "napi-build-utils": "^1.0.1", "node-abi": "^2.21.0", - "noop-logger": "^0.1.1", "npmlog": "^4.0.1", "pump": "^3.0.0", "rc": "^1.2.7", @@ -15035,13 +15040,6 @@ "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - } } }, "react": { @@ -15970,14 +15968,14 @@ "dev": true }, "sharp": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.1.tgz", - "integrity": "sha512-4mCGMEN4ntaVuFGwHx7FvkJQkIgbI+S+F9a3bI7ugdvKjPr4sF7/ibvlRKhJyzhoQi+ODM+XYY1de8xs7MHbfA==", + "version": "0.28.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.3.tgz", + "integrity": "sha512-21GEP45Rmr7q2qcmdnjDkNP04Ooh5v0laGS5FDpojOO84D1DJwUijLiSq8XNNM6e8aGXYtoYRh3sVNdm8NodMA==", "requires": { "color": "^3.1.3", "detect-libc": "^1.0.3", - "node-addon-api": "^3.1.0", - "prebuild-install": "^6.1.1", + "node-addon-api": "^3.2.0", + "prebuild-install": "^6.1.2", "semver": "^7.3.5", "simple-get": "^3.1.0", "tar-fs": "^2.1.1", @@ -15985,23 +15983,28 @@ }, "dependencies": { "color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz", - "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", "requires": { - "color-convert": "^1.9.1", - "color-string": "^1.5.4" + "color-convert": "^1.9.3", + "color-string": "^1.6.0" } }, "color-string": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz", - "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", + "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", "requires": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, + "node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==" + }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", diff --git a/app/package.json b/app/package.json index 0419b438..b971faf8 100644 --- a/app/package.json +++ b/app/package.json @@ -32,6 +32,7 @@ "mapnik": "^4.5.8", "node-fs": "^0.1.7", "nodemailer": "^6.4.11", + "pg-format": "^1.0.4", "pg-promise": "^8.7.5", "query-string": "^6.13.1", "react": "^17.0.2", @@ -39,7 +40,7 @@ "react-leaflet": "^3.1.0", "react-router-dom": "^5.2.0", "serialize-javascript": "^5.0.1", - "sharp": "^0.28.1", + "sharp": "^0.28.3", "use-throttle": "0.0.3" }, "devDependencies": { @@ -53,6 +54,7 @@ "@types/mapbox__sphericalmercator": "^1.1.3", "@types/node": "^12.12.53", "@types/nodemailer": "^6.4.0", + "@types/pg-format": "^1.0.2", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", "@types/react-router-dom": "^4.3.5", diff --git a/app/src/api/config/aggregationsConfig.ts b/app/src/api/config/aggregationsConfig.ts new file mode 100644 index 00000000..a9ab5ffe --- /dev/null +++ b/app/src/api/config/aggregationsConfig.ts @@ -0,0 +1,22 @@ +import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields'; + +export type AggregationMethod = 'countTrue'; + +export interface AggregationConfig { + aggregateFieldName: keyof typeof buildingAttributesConfig; + aggregationMethod: AggregationMethod; +}; + +/** + * Configuration for building-user attribute aggregations. + * The config defines how attributes that are collected per building, per user are aggregated into per building attributes. + * An example is the building like mechanism: + */ +export const aggregationsConfig: { [key in keyof typeof buildingUserAttributesConfig]?: AggregationConfig[]} = { + community_like: [ + { + aggregateFieldName: 'likes_total', + aggregationMethod: 'countTrue' + } + ] +}; \ No newline at end of file diff --git a/app/src/api/config/dataFields.ts b/app/src/api/config/dataFields.ts index 6086f2e2..28e7ead7 100644 --- a/app/src/api/config/dataFields.ts +++ b/app/src/api/config/dataFields.ts @@ -2,6 +2,12 @@ import { valueType } from '../../helpers'; /** Configuration for a single data field */ export interface DataFieldConfig { + + /** + * Default: false + */ + perUser?: boolean; + /** * Allow editing the field through the API? */ @@ -41,7 +47,10 @@ export interface DataFieldConfig { sqlCast?: 'json' | 'jsonb'; } -export const dataFieldsConfig = valueType()({ /* eslint-disable @typescript-eslint/camelcase */ +export const buildingAttributesConfig = valueType()({ /* eslint-disable @typescript-eslint/camelcase */ + ref_toid: { + edit: false + }, ref_osm_id: { edit: true, }, @@ -268,5 +277,13 @@ export const dataFieldsConfig = valueType()({ /* eslint-disable } }); -export type Building = { [k in keyof typeof dataFieldsConfig]: any }; -export type BuildingUpdate = Partial; + +export const buildingUserAttributesConfig = valueType()({ + community_like: { + perUser: true, + edit: true, + verify: false, + }, +}); + +export const allAttributesConfig = Object.assign({}, buildingAttributesConfig, buildingUserAttributesConfig); diff --git a/app/src/api/config/fieldSchemaConfig.ts b/app/src/api/config/fieldSchemaConfig.ts index ea530d51..c0daa83b 100644 --- a/app/src/api/config/fieldSchemaConfig.ts +++ b/app/src/api/config/fieldSchemaConfig.ts @@ -1,8 +1,8 @@ import { JSONSchemaType } from 'ajv'; import { SomeJSONSchema } from 'ajv/dist/types/json-schema'; -import { dataFieldsConfig } from './dataFields'; +import { allAttributesConfig } from './dataFields'; -export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */ +export const fieldSchemaConfig: { [key in keyof typeof allAttributesConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */ demolished_buildings: { type: 'array', diff --git a/app/src/api/controllers/buildingController.ts b/app/src/api/controllers/buildingController.ts index d95a5462..0a1674ba 100644 --- a/app/src/api/controllers/buildingController.ts +++ b/app/src/api/controllers/buildingController.ts @@ -2,6 +2,7 @@ import express from 'express'; import { ApiUserError } from '../errors/api'; import { UserError } from '../errors/general'; +import { parseBooleanExact } from '../../helpers'; import { parsePositiveIntParam, processParam } from '../parameters'; import asyncController from '../routes/asyncController'; import * as buildingService from '../services/building/base'; @@ -39,8 +40,18 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => { const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true); + const returnUserAttributes = parseBooleanExact(String(req.query.user_attributes)); + + let userDataOptions = null; + if(returnUserAttributes) { + if(!req.session.user_id) { + return res.send({ error: 'Must be logged in' }); + } + userDataOptions = { userId: req.session.user_id, userAttributes: true}; + } + try { - const result = await buildingService.getBuildingById(buildingId); + const result = await buildingService.getBuildingById(buildingId, { userDataOptions }); res.send(result); } catch(error) { console.error(error); @@ -50,44 +61,43 @@ const getBuildingById = asyncController(async (req: express.Request, res: expres // POST building attribute updates const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => { - let user_id; - - if (req.session.user_id) { - user_id = req.session.user_id; - } else if (req.query.api_key) { - try { - const user = await userService.authAPIUser(String(req.query.api_key)); - user_id = user.user_id; - } catch(err) { - console.error(err); - res.send({ error: 'Must be logged in' }); - } - } else { - res.send({ error: 'Must be logged in' }); + let userId: string; + + try { + userId = req.session.user_id ?? ( + req.query.api_key && await userService.authAPIUser(String(req.query.api_key)) + ); + } catch(error) { + console.error(error); } - if (user_id) { - await updateBuilding(req, res, user_id); + if(!userId) { + return res.send({ error: 'Must be logged in' }); } -}); -async function updateBuilding(req: express.Request, res: express.Response, userId: string) { const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true); - const buildingUpdate = req.body; + const { + attributes = null, + user_attributes: userAttributes = null + } = req.body; - let updatedBuilding: object; try { - updatedBuilding = await buildingService.editBuilding(buildingId, buildingUpdate, userId); + const resultUpdate = await buildingService.editBuilding(buildingId, userId, {attributes, userAttributes}); + + res.send({ + attributes: resultUpdate.attributes, + user_attributes: resultUpdate.userAttributes, + revision_id: resultUpdate.revisionId + }); } catch(error) { if(error instanceof UserError) { throw new ApiUserError(error.message, error); } throw error; } +}); - res.send(updatedBuilding); -} // GET building UPRNs const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => { @@ -106,21 +116,19 @@ const getBuildingUPRNsById = asyncController(async (req: express.Request, res: e } }); -// GET whether the user likes a building -const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => { - if (!req.session.user_id) { - return res.send({ like: false }); // not logged in, so cannot have liked +const getBuildingUserAttributesById = asyncController(async (req: express.Request, res: express.Response) => { + if(!req.session.user_id) { + return res.send({ error: 'Must be logged in'}); } const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true); try { - const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id); + const userAttributes = await buildingService.getBuildingUserAttributesById(buildingId, req.session.user_id); - // any value returned means like - res.send({ like: like }); + res.send(userAttributes); } catch(error) { - res.send({ error: 'Database error' }); + res.send({ error: 'Database error'}); } }); @@ -137,31 +145,6 @@ const getBuildingEditHistoryById = asyncController(async (req: express.Request, } }); -// POST update to like/unlike building -const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => { - if (!req.session.user_id) { - return res.send({ error: 'Must be logged in' }); - } - - const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true); - const { like } = req.body; - - let updatedBuilding: object; - try { - updatedBuilding = like ? - await buildingService.likeBuilding(buildingId, req.session.user_id) : - await buildingService.unlikeBuilding(buildingId, req.session.user_id); - } catch(error) { - if(error instanceof UserError) { - throw new ApiUserError(error.message, error); - } - - throw error; - } - - res.send(updatedBuilding); -}); - // GET building attributes (and values) as verified by user const getUserVerifiedAttributes = asyncController(async (req: express.Request, res: express.Response) => { if (!req.session.user_id) { @@ -219,10 +202,9 @@ export default { getBuildingById, updateBuildingById, getBuildingUPRNsById, - getBuildingLikeById, - updateBuildingLikeById, getUserVerifiedAttributes, verifyBuildingAttributes, getBuildingEditHistoryById, - getLatestRevisionId + getLatestRevisionId, + getBuildingUserAttributesById }; diff --git a/app/src/api/dataAccess/aggregate.ts b/app/src/api/dataAccess/aggregate.ts new file mode 100644 index 00000000..15358ec6 --- /dev/null +++ b/app/src/api/dataAccess/aggregate.ts @@ -0,0 +1,26 @@ +import { ITask } from 'pg-promise'; +import format from 'pg-format'; + +import db from '../../db'; +import { DatabaseError } from '../errors/general'; +import { AggregationMethod } from '../config/aggregationsConfig'; + +export type AggregationMethodFunction = (buildingId: number, attributeName: string, t?: ITask) => Promise; + +export async function aggregateCountTrue(buildingId: number, attributeName: string, t?: ITask): Promise { + try { + // use pg-format here instead of pg-promise parameterised queries as they don't support column name from paraemeter + // assume that there won't be more likes than Postgres int range and cast to int + // otherwise the count is returned as a bigint which has less support in node-postgres + const query = format(`SELECT count(*)::int as agg FROM building_user_attributes WHERE building_id = %L::int AND %I = true;`, buildingId, attributeName); + const { agg } = await (t || db).one(query); + + return agg; + } catch(error) { + throw new DatabaseError(error); + } +} + +export const aggregationMethods: Record = { + 'countTrue': aggregateCountTrue +}; \ No newline at end of file diff --git a/app/src/api/dataAccess/building.ts b/app/src/api/dataAccess/building.ts index 0d7115a0..9b97acf3 100644 --- a/app/src/api/dataAccess/building.ts +++ b/app/src/api/dataAccess/building.ts @@ -1,15 +1,17 @@ import { errors, ITask } from 'pg-promise'; +import _ from 'lodash'; import db from '../../db'; -import { dataFieldsConfig } from '../config/dataFields'; +import { allAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields'; +import { BaseBuilding, BuildingAttributes, BuildingUserAttributes } from '../models/building'; import { ArgumentError, DatabaseError } from '../errors/general'; export async function getBuildingData( buildingId: number, lockForUpdate: boolean = false, t?: ITask -) { +): Promise { let buildingData; try { buildingData = await (t || db).one( @@ -55,7 +57,7 @@ export async function insertEditHistoryRevision( const columnConfigLookup = Object.assign( {}, - ...Object.entries(dataFieldsConfig).filter(([, config]) => config.edit || config.derivedEdit).map(([key, { + ...Object.entries(allAttributesConfig).filter(([, config]) => config.edit || config.derivedEdit).map(([key, { asJson = false, sqlCast }]) => ({ [key]: { @@ -70,14 +72,14 @@ export async function updateBuildingData( forwardPatch: object, revisionId: string, t?: ITask -): Promise { +): Promise { const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]); const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig); console.log('Setting', buildingId, sets); try { - return await (t || db).one( + const buildingRow: BaseBuilding = await (t || db).one( `UPDATE buildings SET @@ -90,7 +92,132 @@ export async function updateBuildingData( `, [revisionId, sets, buildingId] ); + + delete buildingRow.building_id; + delete buildingRow.geometry_id; + delete buildingRow.revision_id; + + return buildingRow as BuildingAttributes; } catch(error) { throw new DatabaseError(error); } } + +/** + * Function that ensures a building-user attribute record exists. + * The record is created if there isn't one yet. + */ +async function ensureBuildingUserRecord(buildingId: number, userId: string, t: ITask) { + try { + await t.one( + `SELECT * FROM building_user_attributes WHERE building_id = $1 AND user_id = $2;`, + [buildingId, userId] + ); + } catch(error) { + if(error.code === errors.queryResultErrorCode.noData) { + try { + await t.none( + 'INSERT INTO building_user_attributes (building_id, user_id) VALUES ($1, $2);', + [buildingId, userId] + ); + } catch(error) { + throw new DatabaseError(error); + } + } else { + throw new DatabaseError(error); + } + } +} + +type BuildingUserAttributesRow = BuildingUserAttributes & { + building_id: number; + user_id: string; +} + +export async function updateBuildingUserData( + buildingId: number, + userId: string, + forwardPatch: object, + t?: ITask +) : Promise { + await ensureBuildingUserRecord(buildingId, userId, t); + + const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]); + const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig); + + try { + const buildingUserRow: BuildingUserAttributesRow = await (t || db).one( + `UPDATE + building_user_attributes + SET + $1:raw + WHERE + building_id = $2 + AND user_id = $3 + RETURNING + * + ;`, + [sets, buildingId, userId] + ); + + delete buildingUserRow.building_id; + delete buildingUserRow.user_id; + + return buildingUserRow; + } catch(error) { + throw new DatabaseError(error); + } +} + +async function buildingExists(buildingId: number, t?: ITask) { + return ( + await (t || db).oneOrNone( + 'SELECT building_id FROM buildings WHERE building_id = $1', + [buildingId] + ) + ) !== null; +} + +function makeDefaultUserData() { + return _.mapValues(buildingUserAttributesConfig, () => null); +} + +export async function getBuildingUserData( + buildingId: number, + userId: string, + lockForUpdate: boolean = false, + t?: ITask +): Promise { + try { + const buildingUserRow = await (t || db).oneOrNone( + `SELECT + * + FROM building_user_attributes + WHERE + building_id = $1 + AND user_id = $2 + ${lockForUpdate ? ' FOR UPDATE' : ''}; + `, + [buildingId, userId] + ); + + if(buildingUserRow) { + delete buildingUserRow.building_id; + delete buildingUserRow.user_id; + + return buildingUserRow; + } else { + if(await buildingExists(buildingId, t)) { + return makeDefaultUserData(); + } else { + throw new ArgumentError(`Building ID ${buildingId} does not exist`, 'buildingId'); + } + } + + } catch(error) { + if(error instanceof ArgumentError) { + throw error; + } + throw new DatabaseError(error); + } +} \ No newline at end of file diff --git a/app/src/api/dataAccess/like.ts b/app/src/api/dataAccess/like.ts deleted file mode 100644 index bb386125..00000000 --- a/app/src/api/dataAccess/like.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { errors, ITask } from 'pg-promise'; - -import db from '../../db'; -import { DatabaseError, InvalidOperationError } from '../errors/general'; - -export async function getBuildingLikeCount(buildingId: number, t?: ITask): Promise { - try { - // assume that there won't be more likes than Postgres int range and cast to int - // otherwise the count is return as a bigint which has less support in noode-postgres - const result = await (t || db).one( - 'SELECT count(*)::int as likes FROM building_user_likes WHERE building_id = $1;', - [buildingId] - ); - - return result.likes; - } catch(error) { - throw new DatabaseError(error); - } -} - -export async function addBuildingUserLike(buildingId: number, userId: string, t?: ITask): Promise { - try { - return await (t || db).none( - 'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);', - [buildingId, userId] - ); - } catch(error) { - if(error.detail?.includes('already exists')) { - throw new InvalidOperationError('User already likes this building'); - } - throw new DatabaseError(error); - } -} - -export async function removeBuildingUserLike(buildingId: number, userId: string, t?: ITask): Promise { - let result; - try { - result = await t.result( - 'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;', - [buildingId, userId] - ); - } catch(error) { - throw new DatabaseError(error); - } - - if (result.rowCount === 0) { - throw new InvalidOperationError("User doesn't like the building, cannot unlike"); - } -} diff --git a/app/src/api/dataAccess/transaction.ts b/app/src/api/dataAccess/transaction.ts new file mode 100644 index 00000000..3f026fde --- /dev/null +++ b/app/src/api/dataAccess/transaction.ts @@ -0,0 +1,13 @@ +import { ITask } from 'pg-promise'; + +import db from '../../db'; + +// Create a transaction mode (serializable, read-write): +const serializableMode = new db.$config.pgp.txMode.TransactionMode({ + tiLevel: db.$config.pgp.txMode.isolationLevel.serializable, + readOnly: false +}); + +export function startUpdateTransaction(cb: (t: ITask) => Promise): Promise { + return db.tx({mode: serializableMode}, cb); +} \ No newline at end of file diff --git a/app/src/api/models/building.ts b/app/src/api/models/building.ts new file mode 100644 index 00000000..b1b98a8e --- /dev/null +++ b/app/src/api/models/building.ts @@ -0,0 +1,24 @@ +import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields'; + +export type BuildingAttributes = { [k in keyof typeof buildingAttributesConfig]: any }; +export type BuildingUserAttributes = { [k in keyof typeof buildingUserAttributesConfig]: any }; + +export interface BuildingUpdate { + attributes?: Partial; + userAttributes?: Partial; + revisionId?: string; +}; + +export interface BuildingIdentifiers { + building_id: number; + geometry_id: number; + revision_id: string; +} + +export type BaseBuilding = BuildingIdentifiers & BuildingAttributes; + +export interface Building extends BaseBuilding { + user_attributes: BuildingUserAttributes; + edit_history: any[]; + verified: any; +} \ No newline at end of file diff --git a/app/src/api/routes/buildingsRouter.ts b/app/src/api/routes/buildingsRouter.ts index e3102f29..c8124a29 100644 --- a/app/src/api/routes/buildingsRouter.ts +++ b/app/src/api/routes/buildingsRouter.ts @@ -26,10 +26,6 @@ router.route('/:building_id.json') // GET building UPRNs router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById); -// GET/POST like building -router.route('/:building_id/like.json') - .get(buildingController.getBuildingLikeById) - .post(buildingController.updateBuildingLikeById); // POST verify building attribute router.route('/:building_id/verify.json') diff --git a/app/src/api/services/building/base.ts b/app/src/api/services/building/base.ts index ef55fea5..d531eebf 100644 --- a/app/src/api/services/building/base.ts +++ b/app/src/api/services/building/base.ts @@ -4,56 +4,56 @@ */ import _ from 'lodash'; -import { pickFields } from '../../../helpers'; -import { dataFieldsConfig } from '../../config/dataFields'; import * as buildingDataAccess from '../../dataAccess/building'; -import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate'; -import { validateBuildingUpdate } from '../domainLogic/validateBuildingUpdate'; - +import { Building, BuildingUserAttributes } from '../../models/building'; 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); +export interface BuildingMetadataOptions { + editHistory?: boolean; + verified?: boolean; - building.edit_history = await getBuildingEditHistory(id); - building.verified = await getBuildingVerifications(building); - - return building; - } catch(error) { - console.error(error); - return undefined; + userDataOptions?: { + userId: string; + userAttributes?: boolean; } } -/** - * List of fields for which modification is allowed - * (directly by the user, or for fields that are derived from others) - */ -const FINAL_FIELD_EDIT_ALLOWLIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit || value.derivedEdit).map(([key]) => key)); +export async function getBuildingById( + buildingId: number, + { + editHistory = true, + verified = true, + userDataOptions + }: BuildingMetadataOptions = {} +) { + const baseBuilding = await buildingDataAccess.getBuildingData(buildingId); + const building: Partial = {...baseBuilding}; -export async function editBuilding(buildingId: number, building: any, userId: string): Promise { // TODO add proper building type - return await updateBuildingData(buildingId, userId, async () => { - validateBuildingUpdate(buildingId, building); - const processedBuilding = await processBuildingUpdate(buildingId, building); + if(editHistory) { + building.edit_history = await getBuildingEditHistory(buildingId); + } - // remove read-only fields from consideration - delete processedBuilding.building_id; - delete processedBuilding.revision_id; - delete processedBuilding.geometry_id; + if(verified) { + building.verified = await getBuildingVerifications(baseBuilding); + } - // return whitelisted fields to update - return pickFields(processedBuilding, FINAL_FIELD_EDIT_ALLOWLIST); - }); + if(userDataOptions && userDataOptions.userAttributes) { + building.user_attributes = await getBuildingUserAttributesById(buildingId, userDataOptions.userId); + } + + return building; } +export async function getBuildingUserAttributesById(buildingId: number, userId: string): Promise { + return buildingDataAccess.getBuildingUserData(buildingId, userId); +} + +export * from './edit'; export * from './history'; -export * from './like'; export * from './query'; export * from './uprn'; export * from './verify'; diff --git a/app/src/api/services/building/edit.ts b/app/src/api/services/building/edit.ts new file mode 100644 index 00000000..a25003b4 --- /dev/null +++ b/app/src/api/services/building/edit.ts @@ -0,0 +1,106 @@ +import _ from 'lodash'; + +import { BuildingAttributes, BuildingUpdate, BuildingUserAttributes } from '../../models/building'; +import * as buildingDataAccess from '../../dataAccess/building'; +import { startUpdateTransaction } from '../../dataAccess/transaction'; +import { UserError } from '../../errors/general'; +import { aggregateUserAttributes } from '../domainLogic/aggregateUserAttributes'; +import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate'; +import { validateChangeSet } from '../domainLogic/validateUpdate'; +import { expireBuildingTileCache } from './tileCache'; + + +export async function editBuilding( + buildingId: number, + userId: string, + { attributes, userAttributes } : BuildingUpdate +): Promise { + // Validate externally provided attributes + if(attributes) { + validateChangeSet(attributes, true); + } + + // Validate externally provided user attributes + if(userAttributes) { + validateChangeSet(userAttributes, true); + } + + const finalUpdate = await startUpdateTransaction(async (t) => { + let { + attributes: processedAttributes, + userAttributes: processedUserAttributes + } = await processBuildingUpdate(buildingId, { attributes, userAttributes }, t); + + let resultUserAttributes: BuildingUserAttributes = null; + + if(!_.isEmpty(processedUserAttributes)) { + validateChangeSet(processedUserAttributes, false); + resultUserAttributes = await buildingDataAccess + .updateBuildingUserData(buildingId, userId, processedUserAttributes, t); + } + + processedAttributes = await aggregateUserAttributes( + buildingId, + userId, + { + attributes: processedAttributes, + userAttributes: processedUserAttributes + }, + t + ); + + let resultAttributes: BuildingAttributes = null; + + let revisionId: string; + + if(processedAttributes) { + const oldAttributes = await buildingDataAccess.getBuildingData(buildingId, true, t); + const [forwardPatch, reversePatch] = compare(oldAttributes, processedAttributes); + + if(!_.isEmpty(forwardPatch)) { + revisionId = await buildingDataAccess + .insertEditHistoryRevision(buildingId, userId, forwardPatch, reversePatch, t); + resultAttributes = await buildingDataAccess + .updateBuildingData(buildingId, forwardPatch, revisionId, t); + } else { + revisionId = oldAttributes.revision_id; + } + } + + if (resultAttributes == null && resultUserAttributes == null) { + throw new UserError('No change provided'); + } + + return { + revisionId: revisionId, + attributes: resultAttributes, + userAttributes: resultUserAttributes + }; + }); + + expireBuildingTileCache(buildingId); + + return finalUpdate; +} + +/** + * 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]; +} diff --git a/app/src/api/services/building/like.ts b/app/src/api/services/building/like.ts deleted file mode 100644 index 5420bc2b..00000000 --- a/app/src/api/services/building/like.ts +++ /dev/null @@ -1,49 +0,0 @@ -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); - }, - ); -} diff --git a/app/src/api/services/building/save.ts b/app/src/api/services/building/save.ts deleted file mode 100644 index 98fbef1c..00000000 --- a/app/src/api/services/building/save.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ITask } from 'pg-promise'; -import _ 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) => Promise, - preUpdateDbAction?: (t: ITask) => Promise, -): Promise { - 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]; -} diff --git a/app/src/api/services/building/verify.ts b/app/src/api/services/building/verify.ts index 8baae9e1..b5482a76 100644 --- a/app/src/api/services/building/verify.ts +++ b/app/src/api/services/building/verify.ts @@ -1,9 +1,12 @@ -import { dataFieldsConfig } from '../../config/dataFields'; +import { buildingAttributesConfig } from '../../config/dataFields'; +import { BaseBuilding } from '../../models/building'; import * as buildingDataAccess from '../../dataAccess/building'; import * as verifyDataAccess from '../../dataAccess/verify'; import { DatabaseError } from '../../errors/general'; -const FIELD_VERIFICATION_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.verify === true).map(([key]) => key)); +function canVerify(key: string) { + return buildingAttributesConfig[key].verify === true; +} export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) { // get current building attribute values for comparison @@ -14,7 +17,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(FIELD_VERIFICATION_WHITELIST.has(key)) { + if(canVerify(key)) { // check value against current from database - JSON.stringify as hack for "any" data type if (JSON.stringify(value) == JSON.stringify(building[key])) { try { @@ -49,17 +52,14 @@ export async function getUserVerifiedAttributes(buildingId: number, userId: stri return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId); } -export async function getBuildingVerifications(building) { +export async function getBuildingVerifications(building: BaseBuilding) { const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id); - const verified = {}; - for (const element of FIELD_VERIFICATION_WHITELIST) { - verified[element] = 0; - } + const verified: Record = {}; for (const item of verifications) { if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) { - verified[item.attribute] += 1 + verified[item.attribute] = verified[item.attribute] ?? 0 + 1; } } return verified; diff --git a/app/src/api/services/domainLogic/aggregateUserAttributes.ts b/app/src/api/services/domainLogic/aggregateUserAttributes.ts new file mode 100644 index 00000000..ad930b20 --- /dev/null +++ b/app/src/api/services/domainLogic/aggregateUserAttributes.ts @@ -0,0 +1,23 @@ +import { ITask } from 'pg-promise'; +import { BuildingAttributes, BuildingUpdate } from '../../models/building'; +import { aggregationsConfig } from '../../config/aggregationsConfig'; +import { aggregationMethods } from '../../dataAccess/aggregate'; + +export async function aggregateUserAttributes( + buildingId: number, + userId: string, + { attributes, userAttributes } : BuildingUpdate, + t?: ITask +): Promise> { + const derivedAttributes: Partial = {}; + + for(let [key, aggregations] of Object.entries(aggregationsConfig)) { + if(key in userAttributes) { + for(let config of aggregations) { + derivedAttributes[config.aggregateFieldName] = await aggregationMethods[config.aggregationMethod](buildingId, key, t); + } + } + } + + return Object.assign({}, attributes, derivedAttributes); +} \ No newline at end of file diff --git a/app/src/api/services/domainLogic/processBuildingUpdate.ts b/app/src/api/services/domainLogic/processBuildingUpdate.ts index 6c5fb1b5..2169c793 100644 --- a/app/src/api/services/domainLogic/processBuildingUpdate.ts +++ b/app/src/api/services/domainLogic/processBuildingUpdate.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; +import { ITask } from 'pg-promise'; import { hasAnyOwnProperty } from '../../../helpers'; -import { Building, BuildingUpdate } from '../../config/dataFields'; +import { BaseBuilding, BuildingAttributes, BuildingUpdate } from '../../models/building'; import { getBuildingData } from '../../dataAccess/building'; import { ArgumentError } from '../../errors/general'; @@ -10,7 +11,11 @@ import { updateLandUse } from './landUse'; /** * Process land use classifications - derive land use order from land use groups */ -async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise { +async function processCurrentLandUseClassifications( + buildingId: number, + buildingUpdate: Partial, + t?: ITask +): Promise { const currentBuildingData = await getBuildingData(buildingId); try { @@ -39,10 +44,14 @@ async function processCurrentLandUseClassifications(buildingId: number, building /** * Process Dynamics data - check field relationships and sort demolished buildings by construction date */ -async function processDynamicsDemolishedBuildings(buildingId: number, buildingUpdate: BuildingUpdate): Promise { +async function processDynamicsDemolishedBuildings( + buildingId: number, + attributesUpdate: Partial, + t?: ITask +): Promise> { const currentBuildingData = await getBuildingData(buildingId); - const afterUpdate: Building = Object.assign({}, currentBuildingData, buildingUpdate); + const afterUpdate: BaseBuilding = Object.assign({}, currentBuildingData, attributesUpdate); const hasDemolished: boolean = afterUpdate.dynamics_has_demolished_buildings; const demolishedList: any[] = afterUpdate.demolished_buildings; @@ -57,24 +66,27 @@ async function processDynamicsDemolishedBuildings(buildingId: number, buildingUp } } - if(buildingUpdate.demolished_buildings != undefined) { - buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min); + if(attributesUpdate.demolished_buildings != undefined) { + attributesUpdate.demolished_buildings = attributesUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min); } - return buildingUpdate; + return attributesUpdate; } /** * Define any custom processing logic for specific building attributes */ -export async function processBuildingUpdate(buildingId: number, buildingUpdate: BuildingUpdate): Promise { - if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) { - buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); +export async function processBuildingUpdate(buildingId: number, {attributes, userAttributes}: BuildingUpdate, t?: ITask): Promise { + if(hasAnyOwnProperty(attributes, ['current_landuse_group'])) { + attributes = await processCurrentLandUseClassifications(buildingId, attributes, t); } - if(hasAnyOwnProperty(buildingUpdate, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) { - buildingUpdate = await processDynamicsDemolishedBuildings(buildingId, buildingUpdate); + if(hasAnyOwnProperty(attributes, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) { + attributes = await processDynamicsDemolishedBuildings(buildingId, attributes, t); } - return buildingUpdate; + return { + attributes, + userAttributes + }; } diff --git a/app/src/api/services/domainLogic/validateBuildingUpdate.ts b/app/src/api/services/domainLogic/validateBuildingUpdate.ts deleted file mode 100644 index 8dcc2470..00000000 --- a/app/src/api/services/domainLogic/validateBuildingUpdate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Ajv from 'ajv'; -import addFormats from 'ajv-formats'; - -import { mapObject } from '../../../helpers'; -import { InvalidFieldError, FieldTypeError } from '../../errors/general'; -import { dataFieldsConfig } from '../../config/dataFields'; -import { fieldSchemaConfig } from '../../config/fieldSchemaConfig'; - -const ajv = new Ajv(); -addFormats(ajv); - -const compiledSchemas = mapObject(fieldSchemaConfig, ([, val]) => ajv.compile(val)) - -const EXTERNAL_FIELD_EDIT_ALLOWLIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit).map(([key]) => key)); - -export function validateBuildingUpdate(buildingId: number, building: any) { - for(const field of Object.keys(building)) { - if(!EXTERNAL_FIELD_EDIT_ALLOWLIST.has(field)) { - throw new InvalidFieldError('Field is not editable', field); - } - - if(field in compiledSchemas) { - if(!compiledSchemas[field](building[field])) { - throw new FieldTypeError('Invalid format of data sent', field); - } - } - } -} \ No newline at end of file diff --git a/app/src/api/services/domainLogic/validateUpdate.ts b/app/src/api/services/domainLogic/validateUpdate.ts new file mode 100644 index 00000000..1ea25cf5 --- /dev/null +++ b/app/src/api/services/domainLogic/validateUpdate.ts @@ -0,0 +1,39 @@ +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import _ from 'lodash'; + +import { InvalidFieldError, FieldTypeError } from '../../errors/general'; +import { allAttributesConfig } from '../../config/dataFields'; +import { BuildingAttributes, BuildingUserAttributes } from '../../models/building'; +import { fieldSchemaConfig } from '../../config/fieldSchemaConfig'; + +const ajv = new Ajv(); +addFormats(ajv); + +const compiledSchemas = _.mapValues(fieldSchemaConfig, (val) => ajv.compile(val)); + +function canEdit(key: string, allowDerived: boolean = false) { + const config = allAttributesConfig[key]; + + return config.edit || (allowDerived && config.derivedEdit); +} + +export function validateFieldChange(field: string, value: any, isExternal: boolean = true) { + const allowDerived = !isExternal; + if(!canEdit(field, allowDerived)) { + throw new InvalidFieldError('Field is not editable', field); + } + + if(field in compiledSchemas) { + if(!compiledSchemas[field](value)) { + throw new FieldTypeError('Invalid format of data sent', field); + } + } +} + +export function validateChangeSet( + attributes: Partial | Partial, + isExternal: boolean = true +) { + _.forIn(attributes, (value, fieldKey) => validateFieldChange(fieldKey, value, isExternal)); +} \ No newline at end of file diff --git a/app/src/api/services/user.ts b/app/src/api/services/user.ts index da1aae5b..bb9163f2 100644 --- a/app/src/api/services/user.ts +++ b/app/src/api/services/user.ts @@ -142,9 +142,9 @@ async function getNewUserAPIKey(id: string) { } } -async function authAPIUser(key: string) { +async function authAPIUser(key: string): Promise { try { - return await db.one( + const { user_id } = await db.one( `SELECT user_id FROM @@ -155,6 +155,8 @@ async function authAPIUser(key: string) { key ] ); + + return user_id; } catch(error) { console.error('Error:', error); return undefined; diff --git a/app/src/client.tsx b/app/src/client.tsx index 6cc4fab5..e9f11037 100644 --- a/app/src/client.tsx +++ b/app/src/client.tsx @@ -15,7 +15,6 @@ hydrate( diff --git a/app/src/frontend/api-data/building-update.ts b/app/src/frontend/api-data/building-update.ts new file mode 100644 index 00000000..fe5a2085 --- /dev/null +++ b/app/src/frontend/api-data/building-update.ts @@ -0,0 +1,48 @@ +import { BuildingAttributes, BuildingEdits, BuildingUserAttributes } from '../models/building'; +import { apiPost } from '../apiHelpers'; +import { buildingUserFields, dataFields } from '../config/data-fields-config'; + +export type UpdatedBuilding = Partial & Partial & { + revision_id: string; +}; + +function makeUpdateData(edits: BuildingEdits) { + const data = { + attributes: {}, + user_attributes: {} + }; + + for (let [field, value] of Object.entries(edits)) { + if (dataFields[field]) { + data.attributes[field] = value; + } else if (buildingUserFields[field]) { + data.user_attributes[field] = value; + } + } + + return data; +} + +export async function sendBuildingUpdate(buildingId: number, edits: BuildingEdits): Promise { + const requestData = makeUpdateData(edits); + const data = await apiPost( + `/api/buildings/${buildingId}.json`, + requestData + ); + + if (data.error) { + throw data.error; + } else { + const { + revision_id, + attributes, + user_attributes + } = data; + + return { + revision_id, + ...attributes, + ...user_attributes + }; + } +} \ No newline at end of file diff --git a/app/src/frontend/hooks/use-building-data.ts b/app/src/frontend/api-data/use-building-data.ts similarity index 68% rename from app/src/frontend/hooks/use-building-data.ts rename to app/src/frontend/api-data/use-building-data.ts index 1263f076..185a6dc4 100644 --- a/app/src/frontend/hooks/use-building-data.ts +++ b/app/src/frontend/api-data/use-building-data.ts @@ -3,7 +3,14 @@ import { useCallback, useEffect, useState } from 'react'; import { Building, BuildingAttributeVerificationCounts } from '../models/building'; import { apiGet } from '../apiHelpers'; -export function useBuildingData(buildingId: number, preloadedData: Building): [Building, (updatedBuilding: Building) => void, () => void] { +/** + * + * @param buildingId Requested building ID + * @param preloadedData Data preloaded through SSR, to return before the request is first sent + * @param includeUserAttributes Should the building-user attributes be included in the result? This requires login session cookies to be present + * @returns + */ +export function useBuildingData(buildingId: number, preloadedData: Building, includeUserAttributes: boolean = false): [Building, (updatedBuilding: Building) => void, () => void] { const [buildingData, setBuildingData] = useState(preloadedData); const [isOld, setIsOld] = useState(preloadedData == undefined); @@ -14,12 +21,14 @@ export function useBuildingData(buildingId: number, preloadedData: Building): [B return; } try { - const [building, buildingUprns] = await Promise.all([ - apiGet(`/api/buildings/${buildingId}.json`), + let [building, buildingUprns] = await Promise.all([ + apiGet(`/api/buildings/${buildingId}.json${includeUserAttributes ? '?user_attributes=true' : ''}`), apiGet(`/api/buildings/${buildingId}/uprns.json`) ]); building.uprns = buildingUprns.uprns; + building = Object.assign(building, {...building.user_attributes}); + delete building.user_attributes; setBuildingData(building); } catch(error) { diff --git a/app/src/frontend/hooks/use-revision.tsx b/app/src/frontend/api-data/use-revision.tsx similarity index 100% rename from app/src/frontend/hooks/use-revision.tsx rename to app/src/frontend/api-data/use-revision.tsx diff --git a/app/src/frontend/hooks/use-user-verified-data.ts b/app/src/frontend/api-data/use-user-verified-data.ts similarity index 100% rename from app/src/frontend/hooks/use-user-verified-data.ts rename to app/src/frontend/api-data/use-user-verified-data.ts diff --git a/app/src/frontend/app.tsx b/app/src/frontend/app.tsx index b9fcfa4f..c505bb54 100644 --- a/app/src/frontend/app.tsx +++ b/app/src/frontend/app.tsx @@ -32,7 +32,6 @@ import { NotFound } from './pages/not-found'; interface AppProps { user?: User; building?: Building; - building_like?: boolean; user_verified?: UserVerified; revisionId: string; } @@ -83,7 +82,6 @@ export const App: React.FC = props => { diff --git a/app/src/frontend/building/building-view.tsx b/app/src/frontend/building/building-view.tsx index 0bbcc888..51a939a5 100644 --- a/app/src/frontend/building/building-view.tsx +++ b/app/src/frontend/building/building-view.tsx @@ -11,10 +11,8 @@ interface BuildingViewProps { cat: Category; mode: 'view' | 'edit'; building?: Building; - building_like?: boolean; user_verified?: any; onBuildingUpdate: (buildingId: number, updatedData: Building) => void; - onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void; onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void; } diff --git a/app/src/frontend/building/data-components/like-data-entry.tsx b/app/src/frontend/building/data-components/like-data-entry.tsx index 3fdb913a..5d1b32e4 100644 --- a/app/src/frontend/building/data-components/like-data-entry.tsx +++ b/app/src/frontend/building/data-components/like-data-entry.tsx @@ -1,47 +1,43 @@ import React, { Fragment } from 'react'; import { NavLink } from 'react-router-dom'; -import Tooltip from '../../components/tooltip'; -import { Category } from '../../config/categories-config'; +import { buildingUserFields, dataFields } from '../../config/data-fields-config'; +import { CopyProps } from '../data-containers/category-view-props'; +import { DataTitleCopyable } from './data-title'; interface LikeDataEntryProps { mode: 'view' | 'edit' | 'multi-edit'; - userLike: boolean; - totalLikes: number; - onLike: (userLike: boolean) => void; + userValue: boolean; + aggregateValue: number; + copy: CopyProps; + onChange: (key: string, value: boolean) => void; } const LikeDataEntry: React.FunctionComponent = (props) => { - const data_string = JSON.stringify({like: true}); + const fieldName = 'community_like'; + return ( <> -
-
- - Like more - - -
- -
+

{ - (props.totalLikes != null)? - (props.totalLikes === 1)? - `${props.totalLikes} person likes this building` - : `${props.totalLikes} people like this building` + (props.aggregateValue != null)? + (props.aggregateValue === 1)? + `${props.aggregateValue} person likes this building` + : `${props.aggregateValue} people like this building` : "0 people like this building so far - you could be the first!" }

diff --git a/app/src/frontend/building/data-container.tsx b/app/src/frontend/building/data-container.tsx index e90a71a0..dc66eb07 100644 --- a/app/src/frontend/building/data-container.tsx +++ b/app/src/frontend/building/data-container.tsx @@ -1,12 +1,14 @@ import React, { Fragment } from 'react'; import { NavLink, Redirect } from 'react-router-dom'; import Confetti from 'canvas-confetti'; +import _ from 'lodash'; import { apiPost } from '../apiHelpers'; +import { sendBuildingUpdate } from '../api-data/building-update'; import ErrorBox from '../components/error-box'; import InfoBox from '../components/info-box'; import { compareObjects } from '../helpers'; -import { Building, BuildingAttributes, UserVerified } from '../models/building'; +import { Building, BuildingEdits, BuildingUserAttributes, UserVerified } from '../models/building'; import { User } from '../models/user'; import ContainerHeader from './container-header'; @@ -26,10 +28,8 @@ interface DataContainerProps { user?: User; mode: 'view' | 'edit'; building?: Building; - building_like?: boolean; user_verified?: any; onBuildingUpdate: (buildingId: number, updatedData: Building) => void; - onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void; onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void; } @@ -39,7 +39,7 @@ interface DataContainerState { keys_to_copy: {[key: string]: boolean}; currentBuildingId: number; currentBuildingRevisionId: number; - buildingEdits: Partial; + buildingEdits: BuildingEdits; } export type DataContainerType = React.ComponentType; @@ -68,7 +68,6 @@ const withCopyEdit: (wc: React.ComponentType) => DataContaine this.handleChange = this.handleChange.bind(this); this.handleReset = this.handleReset.bind(this); - this.handleLike = this.handleLike.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.handleVerify = this.handleVerify.bind(this); this.handleSaveAdd = this.handleSaveAdd.bind(this); @@ -78,7 +77,7 @@ const withCopyEdit: (wc: React.ComponentType) => DataContaine this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this); } - static getDerivedStateFromProps(props, state) { + static getDerivedStateFromProps(props, state): DataContainerState { const newBuildingId = props.building == undefined ? undefined : props.building.building_id; const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id; if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) { @@ -122,9 +121,8 @@ const withCopyEdit: (wc: React.ComponentType) => DataContaine } isEdited() { - const edits = this.state.buildingEdits; // check if the edits object has any fields - return Object.entries(edits).length !== 0; + return !_.isEmpty(this.state.buildingEdits); } clearEdits() { @@ -166,46 +164,15 @@ const withCopyEdit: (wc: React.ComponentType) => DataContaine this.clearEdits(); } - /** - * Handle likes separately - * - like/love reaction is limited to set/unset per user - * - * @param {*} event - */ - async handleLike(like: boolean) { - try { - const data = await apiPost( - `/api/buildings/${this.props.building.building_id}/like.json`, - {like: like} - ); - - if (data.error) { - this.setState({error: data.error}); - } else { - // like endpoint returns whole building data so we can update both - this.props.onBuildingUpdate(this.props.building.building_id, data); - this.props.onBuildingLikeUpdate(this.props.building.building_id, like); - } - } catch(err) { - this.setState({error: err}); - } - } - - async doSubmit(edits: Partial) { + async doSubmit(edits: Partial) { this.setState({error: undefined}); + try { - const data = await apiPost( - `/api/buildings/${this.props.building.building_id}.json`, - edits - ); - - if (data.error) { - this.setState({error: data.error}); - } else { - this.props.onBuildingUpdate(this.props.building.building_id, data); - } - } catch(err) { - this.setState({error: err}); + const buildingUpdate = await sendBuildingUpdate(this.props.building.building_id, edits); + const updatedBuilding = Object.assign({}, this.props.building, buildingUpdate); + this.props.onBuildingUpdate(this.props.building.building_id, updatedBuilding); + } catch(error) { + this.setState({ error }); } } @@ -351,12 +318,10 @@ const withCopyEdit: (wc: React.ComponentType) => DataContaine ) => DataContaine void; - onLike: (like: boolean) => void; onVerify: (slug: string, verify: boolean, x: number, y: number) => void; /* Special handler for adding and immediately saving a new item of an array-like attribute */ diff --git a/app/src/frontend/building/data-containers/community.tsx b/app/src/frontend/building/data-containers/community.tsx index d39e7bef..6bde785e 100644 --- a/app/src/frontend/building/data-containers/community.tsx +++ b/app/src/frontend/building/data-containers/community.tsx @@ -1,23 +1,43 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import withCopyEdit from '../data-container'; import LikeDataEntry from '../data-components/like-data-entry'; import { CategoryViewProps } from './category-view-props'; +import InfoBox from '../../components/info-box'; +import { LogicalDataEntry } from '../data-components/logical-data-entry/logical-data-entry'; +import { dataFields } from '../../config/data-fields-config'; /** * Community view/edit section */ const CommunityView: React.FunctionComponent = (props) => ( - + <> + + Can you help add information on how well you think the building works, and on if it is in public ownership? + -

{props.intro}

-
    + copy={props.copy} + /> +{/* + */} + {/*

    {props.intro}

    */} + {/*
    • Is this a publicly owned building?
    • { // "slug": "community_publicly_owned", @@ -33,8 +53,8 @@ const CommunityView: React.FunctionComponent = (props) => ( // "slug": "community_asset", // "type": "checkbox" } -
    - +
*/} + ); const CommunityContainer = withCopyEdit(CommunityView); diff --git a/app/src/frontend/building/multi-edit.tsx b/app/src/frontend/building/multi-edit.tsx index dd130233..c7bb5745 100644 --- a/app/src/frontend/building/multi-edit.tsx +++ b/app/src/frontend/building/multi-edit.tsx @@ -4,10 +4,9 @@ import { Link } from 'react-router-dom'; import { useMultiEditData } from '../hooks/use-multi-edit-data'; import ErrorBox from '../components/error-box'; import InfoBox from '../components/info-box'; -import { dataFields } from '../config/data-fields-config'; +import { allFieldsConfig } from '../config/data-fields-config'; import DataEntry from './data-components/data-entry'; -import { Category } from '../config/categories-config'; interface MultiEditProps { category: string; @@ -16,34 +15,24 @@ interface MultiEditProps { const MultiEdit: React.FC = (props) => { const [data, error] = useMultiEditData(); - const isLike = props.category === Category.Community; - return (
-

{ - isLike ? - <>Like Me! : - <>Copy {props.category} data - }

+

Paste {props.category} data

{ error ? : - + } { - !isLike && data && + data && Object.keys(data).map((key => ( (currentValue: T, newValue: T): T { } export const MapApp: React.FC = props => { + const { user } = useAuth(); const [categoryUrlParam] = useUrlCategoryParam(); const [currentCategory, setCategory] = useState(); @@ -70,8 +70,7 @@ export const MapApp: React.FC = props => { const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam('view', displayCategory); - const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building); - const [buildingLike, updateBuildingLike] = useBuildingLikeData(selectedBuildingId, props.building_like); + const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building, user != undefined); const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified); const [revisionId, updateRevisionId] = useRevisionId(props.revisionId); @@ -94,20 +93,9 @@ export const MapApp: React.FC = props => { const buildingId = building?.building_id; if(buildingId != undefined && multiEditError == undefined) { - const isLike = currentCategory === Category.Community; - const endpoint = isLike ? - `/api/buildings/${buildingId}/like.json`: - `/api/buildings/${buildingId}.json`; - - const payload = isLike ? {like: true} : multiEditData; - try { - const res = await apiPost(endpoint, payload); - if(res.error) { - console.error({ error: res.error }); - } else { - updateRevisionId(res.revision_id); - } + const updatedBuilding = await sendBuildingUpdate(buildingId, multiEditData); + updateRevisionId(updatedBuilding.revision_id); } catch(error) { console.error({ error }); } @@ -124,13 +112,6 @@ export const MapApp: React.FC = props => { } }, [selectedBuildingId, building, updateBuilding, updateRevisionId]); - const handleBuildingLikeUpdate = useCallback((buildingId: number, updatedData: boolean) => { - // only update current building data if the IDs match - if(buildingId === selectedBuildingId) { - updateBuildingLike(updatedData); - } - }, [selectedBuildingId, updateBuildingLike]); - const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => { // only update current building data if the IDs match if(buildingId === selectedBuildingId) { @@ -162,10 +143,8 @@ export const MapApp: React.FC = props => { mode={viewEditMode} cat={displayCategory} building={building} - building_like={buildingLike} user_verified={userVerified ?? {}} onBuildingUpdate={handleBuildingUpdate} - onBuildingLikeUpdate={handleBuildingLikeUpdate} onUserVerifiedUpdate={handleUserVerifiedUpdate} /> diff --git a/app/src/frontend/models/building.ts b/app/src/frontend/models/building.ts index b9be5a9e..9d29d84a 100644 --- a/app/src/frontend/models/building.ts +++ b/app/src/frontend/models/building.ts @@ -1,4 +1,6 @@ -import { dataFields } from '../config/data-fields-config'; +import { buildingUserFields, dataFields } from '../config/data-fields-config'; + +type AttributesBasedOnExample> = {[key in keyof T]: T[key]['example']}; /** * A type representing the types of a building's attributes. @@ -6,16 +8,19 @@ import { dataFields } from '../config/data-fields-config'; * If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here, * that means an example field is most probably missing on one of the config definitions in dataFieldsConfig. */ -export type BuildingAttributes = {[key in keyof typeof dataFields]: (typeof dataFields)[key]['example']}; +export type BuildingAttributes = AttributesBasedOnExample; +export type BuildingUserAttributes = AttributesBasedOnExample; export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number}; export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]}; -export interface Building extends BuildingAttributes { +export interface Building extends BuildingAttributes, BuildingUserAttributes { building_id: number; geometry_id: number; revision_id: string; verified: BuildingAttributeVerificationCounts; } + +export type BuildingEdits = Partial; \ No newline at end of file diff --git a/app/src/frontendRoute.tsx b/app/src/frontendRoute.tsx index 0091ef6e..c9fb1b64 100644 --- a/app/src/frontendRoute.tsx +++ b/app/src/frontendRoute.tsx @@ -6,7 +6,6 @@ import serialize from 'serialize-javascript'; import { getBuildingById, - getBuildingLikeById, getBuildingUPRNsById, getLatestRevisionId, getUserVerifiedAttributes @@ -34,11 +33,13 @@ const frontendRoute = asyncController(async (req: express.Request, res: express. } try { - let [user, building, uprns, buildingLike, userVerified, latestRevisionId] = await Promise.all([ + let [user, building, uprns, userVerified, latestRevisionId] = await Promise.all([ userId ? getUserById(userId) : undefined, - isBuilding ? getBuildingById(buildingId) : undefined, + isBuilding ? getBuildingById( + buildingId, + { userDataOptions: userId ? { userId, userAttributes: true } : null } + ) : undefined, isBuilding ? getBuildingUPRNsById(buildingId) : undefined, - (isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false, (isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {}, getLatestRevisionId() ]); @@ -48,7 +49,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express. } data.user = user; data.building = building; - data.building_like = buildingLike; data.user_verified = userVerified; if (data.building != null) { data.building.uprns = uprns; @@ -59,7 +59,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express. console.error(error); data.user = undefined; data.building = undefined; - data.building_like = undefined; data.user_verified = {} data.latestRevisionId = 0; context.status = 500; @@ -73,7 +72,6 @@ function renderHTML(context, data, req, res) { diff --git a/migrations/unreleased/0xx.building-user-attributes.down.sql b/migrations/unreleased/0xx.building-user-attributes.down.sql new file mode 100644 index 00000000..5a82c0b8 --- /dev/null +++ b/migrations/unreleased/0xx.building-user-attributes.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS building_user_attributes; \ No newline at end of file diff --git a/migrations/unreleased/0xx.building-user-attributes.up.sql b/migrations/unreleased/0xx.building-user-attributes.up.sql new file mode 100644 index 00000000..471769c5 --- /dev/null +++ b/migrations/unreleased/0xx.building-user-attributes.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.building_user_attributes ( + building_id INTEGER REFERENCES buildings, + user_id UUID REFERENCES users, + PRIMARY KEY (building_id, user_id), + + community_like BOOLEAN NOT NULL DEFAULT(FALSE) +); + +CREATE INDEX IF NOT EXISTS user_attrib_building_id_idx ON building_user_attributes (building_id); +CREATE INDEX IF NOT EXISTS user_attrib_building_id_user_id_idx ON building_user_attributes (building_id, user_id); + + +INSERT INTO building_user_attributes (building_id, user_id, community_like) +select building_id, user_id, TRUE as community_like +from building_user_likes; \ No newline at end of file