From a0da41fa923e6fbbe4c2844b3a82de27397ecd98 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 12 Aug 2021 21:11:00 +0100 Subject: [PATCH 01/14] Reformat like data entry --- .../data-components/like-data-entry.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 cdb24d24..3fdb913a 100644 --- a/app/src/frontend/building/data-components/like-data-entry.tsx +++ b/app/src/frontend/building/data-components/like-data-entry.tsx @@ -15,18 +15,27 @@ interface LikeDataEntryProps { const LikeDataEntry: React.FunctionComponent = (props) => { const data_string = JSON.stringify({like: true}); return ( - + <>
- -
+
Like more +
- +
+

{ (props.totalLikes != null)? @@ -36,16 +45,7 @@ const LikeDataEntry: React.FunctionComponent = (props) => { : "0 people like this building so far - you could be the first!" }

- - + ); }; From 2118d6ba7c829e177a7bf4ab62d6c34dd24bb702 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 12 Aug 2021 21:11:36 +0100 Subject: [PATCH 02/14] Add likes_total data definition in API --- app/src/api/config/dataFields.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/api/config/dataFields.ts b/app/src/api/config/dataFields.ts index 523c3111..6086f2e2 100644 --- a/app/src/api/config/dataFields.ts +++ b/app/src/api/config/dataFields.ts @@ -260,6 +260,12 @@ export const dataFieldsConfig = valueType()({ /* eslint-disable asJson: true, sqlCast: 'jsonb', }, + + likes_total: { + edit: false, + derivedEdit: true, + verify: false + } }); export type Building = { [k in keyof typeof dataFieldsConfig]: any }; From c1679a0c35520ababe9fce36cbe5c4b7dcedf9e5 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 12 Aug 2021 21:11:52 +0100 Subject: [PATCH 03/14] Handle likes database count as integer --- app/src/api/dataAccess/like.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/api/dataAccess/like.ts b/app/src/api/dataAccess/like.ts index a9b91174..bb386125 100644 --- a/app/src/api/dataAccess/like.ts +++ b/app/src/api/dataAccess/like.ts @@ -5,8 +5,10 @@ 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(*) as likes FROM building_user_likes WHERE building_id = $1;', + 'SELECT count(*)::int as likes FROM building_user_likes WHERE building_id = $1;', [buildingId] ); From 29ed25f36cb7bc69c0dc53b302d6341ce50e1042 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 23 Aug 2021 02:26:58 +0100 Subject: [PATCH 04/14] Replace likes with generic building-user attribs --- app/package-lock.json | 67 +++++---- app/package.json | 4 +- app/src/api/config/aggregationsConfig.ts | 22 +++ app/src/api/config/dataFields.ts | 23 ++- app/src/api/config/fieldSchemaConfig.ts | 4 +- app/src/api/controllers/buildingController.ts | 102 ++++++------- app/src/api/dataAccess/aggregate.ts | 26 ++++ app/src/api/dataAccess/building.ts | 137 +++++++++++++++++- app/src/api/dataAccess/like.ts | 49 ------- app/src/api/dataAccess/transaction.ts | 13 ++ app/src/api/models/building.ts | 24 +++ app/src/api/routes/buildingsRouter.ts | 4 - app/src/api/services/building/base.ts | 66 ++++----- app/src/api/services/building/edit.ts | 106 ++++++++++++++ app/src/api/services/building/like.ts | 49 ------- app/src/api/services/building/save.ts | 81 ----------- app/src/api/services/building/verify.ts | 18 +-- .../domainLogic/aggregateUserAttributes.ts | 23 +++ .../domainLogic/processBuildingUpdate.ts | 38 +++-- .../domainLogic/validateBuildingUpdate.ts | 28 ---- .../services/domainLogic/validateUpdate.ts | 39 +++++ app/src/api/services/user.ts | 6 +- app/src/client.tsx | 1 - app/src/frontend/api-data/building-update.ts | 48 ++++++ .../{hooks => api-data}/use-building-data.ts | 15 +- .../{hooks => api-data}/use-revision.tsx | 0 .../use-user-verified-data.ts | 0 app/src/frontend/app.tsx | 2 - app/src/frontend/building/building-view.tsx | 2 - .../data-components/like-data-entry.tsx | 46 +++--- app/src/frontend/building/data-container.tsx | 63 ++------ .../data-containers/category-view-props.ts | 2 - .../building/data-containers/community.tsx | 40 +++-- app/src/frontend/building/multi-edit.tsx | 21 +-- app/src/frontend/config/data-fields-config.ts | 22 +++ app/src/frontend/map-app.tsx | 39 ++--- app/src/frontend/models/building.ts | 11 +- app/src/frontendRoute.tsx | 12 +- .../0xx.building-user-attributes.down.sql | 1 + .../0xx.building-user-attributes.up.sql | 15 ++ 40 files changed, 747 insertions(+), 522 deletions(-) create mode 100644 app/src/api/config/aggregationsConfig.ts create mode 100644 app/src/api/dataAccess/aggregate.ts delete mode 100644 app/src/api/dataAccess/like.ts create mode 100644 app/src/api/dataAccess/transaction.ts create mode 100644 app/src/api/models/building.ts create mode 100644 app/src/api/services/building/edit.ts delete mode 100644 app/src/api/services/building/like.ts delete mode 100644 app/src/api/services/building/save.ts create mode 100644 app/src/api/services/domainLogic/aggregateUserAttributes.ts delete mode 100644 app/src/api/services/domainLogic/validateBuildingUpdate.ts create mode 100644 app/src/api/services/domainLogic/validateUpdate.ts create mode 100644 app/src/frontend/api-data/building-update.ts rename app/src/frontend/{hooks => api-data}/use-building-data.ts (68%) rename app/src/frontend/{hooks => api-data}/use-revision.tsx (100%) rename app/src/frontend/{hooks => api-data}/use-user-verified-data.ts (100%) create mode 100644 migrations/unreleased/0xx.building-user-attributes.down.sql create mode 100644 migrations/unreleased/0xx.building-user-attributes.up.sql 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 From 8c8a6a80941c5984ccc5e0feff4733bf424a4fa1 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Fri, 24 Sep 2021 20:31:03 +0300 Subject: [PATCH 05/14] Add user opinion fields to community section --- app/src/api/config/aggregationsConfig.ts | 6 ++ app/src/api/config/dataFields.ts | 20 ++++ app/src/api/config/fieldSchemaConfig.ts | 28 ++++++ .../logical-data-entry/logical-data-entry.tsx | 31 ++++--- .../multi-select-data-entry.tsx | 57 ++++++++++++ ...-entry.tsx => user-opinion-data-entry.tsx} | 28 +++--- .../building/data-containers/community.css | 5 + .../building/data-containers/community.tsx | 89 ++++++++++++++---- app/src/frontend/config/data-fields-config.ts | 91 ++++++++++++++++++- migrations/unreleased/0xx.community.down.sql | 25 +++++ migrations/unreleased/0xx.community.up.sql | 40 ++++++++ 11 files changed, 378 insertions(+), 42 deletions(-) create mode 100644 app/src/frontend/building/data-components/multi-select-data-entry.tsx rename app/src/frontend/building/data-components/{like-data-entry.tsx => user-opinion-data-entry.tsx} (51%) create mode 100644 app/src/frontend/building/data-containers/community.css create mode 100644 migrations/unreleased/0xx.community.down.sql create mode 100644 migrations/unreleased/0xx.community.up.sql diff --git a/app/src/api/config/aggregationsConfig.ts b/app/src/api/config/aggregationsConfig.ts index a9ab5ffe..b822e077 100644 --- a/app/src/api/config/aggregationsConfig.ts +++ b/app/src/api/config/aggregationsConfig.ts @@ -18,5 +18,11 @@ export const aggregationsConfig: { [key in keyof typeof buildingUserAttributesCo aggregateFieldName: 'likes_total', aggregationMethod: 'countTrue' } + ], + community_local_significance: [ + { + aggregateFieldName: 'community_local_significance_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 28e7ead7..a6e5bc76 100644 --- a/app/src/api/config/dataFields.ts +++ b/app/src/api/config/dataFields.ts @@ -274,6 +274,11 @@ export const buildingAttributesConfig = valueType()({ /* eslint edit: false, derivedEdit: true, verify: false + }, + community_local_significance_total: { + edit: false, + derivedEdit: true, + verify: false } }); @@ -284,6 +289,21 @@ export const buildingUserAttributesConfig = valueType()({ edit: true, verify: false, }, + community_type_worth_keeping: { + perUser: true, + edit: true, + verify: false + }, + community_type_worth_keeping_reasons: { + perUser: true, + edit: true, + verify: false + }, + community_local_significance: { + 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 c0daa83b..6e925123 100644 --- a/app/src/api/config/fieldSchemaConfig.ts +++ b/app/src/api/config/fieldSchemaConfig.ts @@ -63,4 +63,32 @@ export const fieldSchemaConfig: { [key in keyof typeof allAttributesConfig]?: So links: string[]; }[]>, + community_type_worth_keeping_reasons: { + type: 'object', + properties: { + external_design: { + type: 'boolean', + nullable: true + }, + internal_design: { + type: 'boolean', + nullable: true + }, + adaptable: { + type: 'boolean', + nullable: true + }, + other: { + type: 'boolean', + nullable: true + } + }, + additionalProperties: false + } as JSONSchemaType<{ + external_design: boolean, + internal_design: boolean, + adaptable: boolean, + other: boolean + }> + } as const; diff --git a/app/src/frontend/building/data-components/logical-data-entry/logical-data-entry.tsx b/app/src/frontend/building/data-components/logical-data-entry/logical-data-entry.tsx index 36f87dea..78cbee8f 100644 --- a/app/src/frontend/building/data-components/logical-data-entry/logical-data-entry.tsx +++ b/app/src/frontend/building/data-components/logical-data-entry/logical-data-entry.tsx @@ -44,6 +44,17 @@ const ToggleButton: React.FC = ({ ); } +const ClearButton = ({ + onClick, + disabled +}) => { + return
+ +
+} + interface LogicalDataEntryProps extends BaseDataEntryProps { value: boolean; disallowTrue?: boolean; @@ -53,7 +64,11 @@ interface LogicalDataEntryProps extends BaseDataEntryProps { export const LogicalDataEntry: React.FC = (props) => { function handleValueChange(e: React.ChangeEvent) { - props.onChange?.(props.slug, e.target.value === 'null' ? null : e.target.value === 'true'); + props.onChange?.(props.slug, e.target.value === 'true'); + } + + function handleClear(e: React.MouseEvent) { + props.onChange?.(props.slug, null); } const isDisabled = props.mode === 'view' || props.disabled; @@ -76,16 +91,6 @@ export const LogicalDataEntry: React.FC = (props) => { uncheckedClassName='btn-outline-dark' onChange={handleValueChange} >Yes - - ? - = (props) => { onChange={handleValueChange} >No
+ { + !isDisabled && props.value != null && + + } ); }; diff --git a/app/src/frontend/building/data-components/multi-select-data-entry.tsx b/app/src/frontend/building/data-components/multi-select-data-entry.tsx new file mode 100644 index 00000000..f1464461 --- /dev/null +++ b/app/src/frontend/building/data-components/multi-select-data-entry.tsx @@ -0,0 +1,57 @@ +import React, { ChangeEvent } from 'react'; + +import { BaseDataEntryProps } from './data-entry'; +import { DataTitleCopyable } from './data-title'; + +interface MultiSelectOption { + key: string; + label: string; +} + +interface MultiSelectDataEntryProps extends BaseDataEntryProps { + value: {[key: string]: boolean}; + options: (MultiSelectOption)[]; + showTitle?: boolean; // TODO make it an option for all input types +} + +export const MultiSelectDataEntry: React.FunctionComponent = (props) => { + const slugWithModifier = props.slug + (props.slugModifier ?? ''); + + function handleChange(e: ChangeEvent) { + const changedKey = e.target.name; + const checked = e.target.checked; + + const newVal = {...props.value, [changedKey]: checked || null}; + + props.onChange(slugWithModifier, newVal); + } + + return ( + <> + {props.showTitle !== false && + + } + { + props.options.map(o => ( + + )) + } + + ); +}; diff --git a/app/src/frontend/building/data-components/like-data-entry.tsx b/app/src/frontend/building/data-components/user-opinion-data-entry.tsx similarity index 51% rename from app/src/frontend/building/data-components/like-data-entry.tsx rename to app/src/frontend/building/data-components/user-opinion-data-entry.tsx index 5d1b32e4..3efe2d61 100644 --- a/app/src/frontend/building/data-components/like-data-entry.tsx +++ b/app/src/frontend/building/data-components/user-opinion-data-entry.tsx @@ -1,48 +1,50 @@ import React, { Fragment } from 'react'; import { NavLink } from 'react-router-dom'; -import { buildingUserFields, dataFields } from '../../config/data-fields-config'; +import { AggregationDescriptionConfig, buildingUserFields, dataFields } from '../../config/data-fields-config'; import { CopyProps } from '../data-containers/category-view-props'; import { DataTitleCopyable } from './data-title'; -interface LikeDataEntryProps { +interface UserOpinionEntryProps { + slug: string; + title: string; mode: 'view' | 'edit' | 'multi-edit'; userValue: boolean; aggregateValue: number; + aggregationDescriptions: AggregationDescriptionConfig; copy: CopyProps; onChange: (key: string, value: boolean) => void; } -const LikeDataEntry: React.FunctionComponent = (props) => { - const fieldName = 'community_like'; +const UserOpinionEntry: React.FunctionComponent = (props) => { return ( <>

{ - (props.aggregateValue != null)? + (props.aggregateValue)? (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!" + `1 person ${props.aggregationDescriptions.one}` + : `${props.aggregateValue} people ${props.aggregationDescriptions.many}` + : `0 people ${props.aggregationDescriptions.zero}` }

); }; -export default LikeDataEntry; +export default UserOpinionEntry; diff --git a/app/src/frontend/building/data-containers/community.css b/app/src/frontend/building/data-containers/community.css new file mode 100644 index 00000000..d807fa79 --- /dev/null +++ b/app/src/frontend/building/data-containers/community.css @@ -0,0 +1,5 @@ +.community-opinion-pane { + padding-top: 0.5em; + padding-bottom: 0.5em; + border-bottom: 1px dashed gray; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-containers/community.tsx b/app/src/frontend/building/data-containers/community.tsx index 6bde785e..e11ddcfb 100644 --- a/app/src/frontend/building/data-containers/community.tsx +++ b/app/src/frontend/building/data-containers/community.tsx @@ -1,31 +1,86 @@ import React from 'react'; import withCopyEdit from '../data-container'; -import LikeDataEntry from '../data-components/like-data-entry'; +import UserOpinionEntry from '../data-components/user-opinion-data-entry'; +import { MultiSelectDataEntry } from '../data-components/multi-select-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'; +import { buildingUserFields, dataFields } from '../../config/data-fields-config'; + +import './community.css'; /** * 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) => { + const worthKeepingReasonsNonEmpty = Object.values(props.building.community_type_worth_keeping_reasons ?? {}).some(x => x); + return <> +
+ + Can you share your opinion on how well the building works? + + -{/* - + + { + props.building.community_type_worth_keeping !== false && + ({ + key, + label: definition.title + })) + } + mode={props.mode} + /> + } + + + +
+ + Can you help add information about public ownership of the building? + {/* = (props) => ( } */} -); +}; const CommunityContainer = withCopyEdit(CommunityView); export default CommunityContainer; diff --git a/app/src/frontend/config/data-fields-config.ts b/app/src/frontend/config/data-fields-config.ts index 00514eb2..3d87ad0d 100644 --- a/app/src/frontend/config/data-fields-config.ts +++ b/app/src/frontend/config/data-fields-config.ts @@ -1,5 +1,12 @@ import { Category } from './categories-config'; + +export interface AggregationDescriptionConfig { + zero: string; + one: string; + many: string; +} + /** * This interface is used only in code which uses dataFields, not in the dataFields definition itself * Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}), @@ -35,6 +42,12 @@ export interface DataFieldDefinition { */ items?: { [key: string]: Omit }; + + /** + * If the defined type is a dictionary, this describes the types of the dictionary's fields + */ + fields?: { [key: string]: Omit} + /** * The example is used to determine the runtime type in which the attribute data is stored (e.g. number, string, object) * This gives the programmer auto-complete of all available building attributes when implementing a category view. @@ -52,6 +65,14 @@ export interface DataFieldDefinition { * By default this is false - fields are treated as not user-specific. */ perUser?: boolean; + + /** + * Only for fields that are aggregations of a building-user field. + * specify what text should be added to the number of users calculated by the aggregation. + * E.g. for user likes, if zero="like this building" then for a building with 0 likes, + * the result will be "0 people like this building" + */ + aggregationDescriptions?: AggregationDescriptionConfig; } export const buildingUserFields = { @@ -61,6 +82,44 @@ export const buildingUserFields = { title: "Do you like this building and think it contributes to the city?", example: true, }, + community_type_worth_keeping: { + perUser: true, + category: Category.Community, + title: "Do you think this type of building is generally worth keeping?", + example: true, + }, + community_type_worth_keeping_reasons: { + perUser: true, + category: Category.Community, + title: 'Why is this type of building worth keeping?', + fields: { + external_design: { + title: "because the external design contributes to the streetscape" + }, + internal_design: { + title: 'because the internal design works well' + }, + adaptable: { + title: 'because the building is adaptable / can be reused to make the city more sustainable' + }, + other: { + title: 'other' + } + }, + example: { + external_design: true, + internal_design: true, + adaptable: false, + other: false + } + }, + + community_local_significance: { + perUser: true, + category: Category.Community, + title: "Do you think this building should be recorded as one of special local significance?", + example: true + } }; @@ -461,9 +520,39 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ category: Category.Community, title: "Total number of likes", example: 100, - tooltip: "People who like the building and think it contributes to the city." + tooltip: "People who like the building and think it contributes to the city.", + aggregationDescriptions: { + zero: "like this building so far", + one: "likes this building", + many: "like this building" + } }, + community_local_significance_total: { + category: Category.Community, + title: "People who think the building should be recorded as one of local significance", + example: 100, + aggregationDescriptions: { + zero: "think this building is of local significance", + one: "thinks this building is of local significance", + many: "think this building is of local significance" + } + }, + + community_publicly_owned: { + category: Category.Community, + title: "Is the building in some form of community ownership?", + example: false + }, + community_public_ownership_form: { + category: Category.Community, + title: "What is the form of community ownership of this building?", + example: "State-owned" + }, + community_public_ownership_source: { + category: Category.Community, + title: "Community ownership source links", + example: "https://example.com" }, dynamics_has_demolished_buildings: { diff --git a/migrations/unreleased/0xx.community.down.sql b/migrations/unreleased/0xx.community.down.sql new file mode 100644 index 00000000..094bde63 --- /dev/null +++ b/migrations/unreleased/0xx.community.down.sql @@ -0,0 +1,25 @@ +-- -- Remove community fields + +-- -- Ownership type, enumerate type from: +-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type; + +-- -- Ownerhsip perception, would you describe this as a community asset? +-- -- Boolean yes / no +-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception; + +-- -- Historic ownership type / perception +-- -- Has this building ever been used for community or public services activities? +-- -- Boolean yes / no +-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic; + +ALTER TABLE building_user_attributes +DROP COLUMN IF EXISTS community_type_worth_keeping; + +ALTER TABLE building_user_attributes +DROP COLUMN IF EXISTS community_type_worth_keeping_reasons; + +ALTER TABLE building_user_attributes +DROP COLUMN IF EXISTS community_local_significance; + +ALTER TABLE buildings +DROP COLUMN IF EXISTS community_local_significance_total; \ No newline at end of file diff --git a/migrations/unreleased/0xx.community.up.sql b/migrations/unreleased/0xx.community.up.sql new file mode 100644 index 00000000..ca426ae1 --- /dev/null +++ b/migrations/unreleased/0xx.community.up.sql @@ -0,0 +1,40 @@ +-- Remove community fields + +-- Ownership type, enumerate type from: +-- + +-- CREATE TYPE ownership_type +-- AS ENUM ('Private individual', +-- 'Private company', +-- 'Private offshore ownership', +-- 'Publicly owned', +-- 'Institutionally owned'); + +-- ALTER TABLE buildings +-- ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual'; + +-- Ownerhsip perception, would you describe this as a community asset? +-- Boolean yes / no +-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which + +-- ALTER TABLE buildings +-- ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null; + +-- Historic ownership type / perception +-- Has this building ever been used for community or public services activities? +-- Boolean yes / no + +-- ALTER TABLE buildings +-- ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null; + +ALTER TABLE building_user_attributes +ADD COLUMN community_type_worth_keeping BOOLEAN NULL; + +ALTER TABLE building_user_attributes +ADD COLUMN community_type_worth_keeping_reasons JSONB DEFAULT '{}'::JSONB; + +ALTER TABLE building_user_attributes +ADD COLUMN community_local_significance BOOLEAN DEFAULT false; + +ALTER TABLE buildings +ADD COLUMN community_local_significance_total INT DEFAULT 0; \ No newline at end of file From 522eff20314ad55d29c1e9d1376f7a88b4ff2cd4 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Fri, 24 Sep 2021 20:32:05 +0300 Subject: [PATCH 06/14] Correct API error on unknown field edit --- app/src/api/services/domainLogic/validateUpdate.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/api/services/domainLogic/validateUpdate.ts b/app/src/api/services/domainLogic/validateUpdate.ts index 1ea25cf5..b9e35d3a 100644 --- a/app/src/api/services/domainLogic/validateUpdate.ts +++ b/app/src/api/services/domainLogic/validateUpdate.ts @@ -12,6 +12,10 @@ addFormats(ajv); const compiledSchemas = _.mapValues(fieldSchemaConfig, (val) => ajv.compile(val)); +function isDefined(key: string) { + return allAttributesConfig[key] !== undefined; +} + function canEdit(key: string, allowDerived: boolean = false) { const config = allAttributesConfig[key]; @@ -19,6 +23,10 @@ function canEdit(key: string, allowDerived: boolean = false) { } export function validateFieldChange(field: string, value: any, isExternal: boolean = true) { + if(!isDefined(field)) { + throw new InvalidFieldError('Field does not exist', field); + } + const allowDerived = !isExternal; if(!canEdit(field, allowDerived)) { throw new InvalidFieldError('Field is not editable', field); From 75f0044b3b70fe070bcdbc3de8b8bfea9394babb Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 27 Sep 2021 13:47:27 +0300 Subject: [PATCH 07/14] Add community fields about public ownership --- app/src/api/config/dataFields.ts | 13 +++ .../multi-select-data-entry.tsx | 2 +- .../data-containers/category-view-props.ts | 4 +- .../building/data-containers/community.tsx | 81 +++++++++++++------ app/src/frontend/config/data-fields-config.ts | 22 +++-- migrations/unreleased/0xx.community.down.sql | 14 +++- migrations/unreleased/0xx.community.up.sql | 20 ++++- 7 files changed, 118 insertions(+), 38 deletions(-) diff --git a/app/src/api/config/dataFields.ts b/app/src/api/config/dataFields.ts index a6e5bc76..8c41c779 100644 --- a/app/src/api/config/dataFields.ts +++ b/app/src/api/config/dataFields.ts @@ -279,7 +279,20 @@ export const buildingAttributesConfig = valueType()({ /* eslint edit: false, derivedEdit: true, verify: false + }, + community_activities: { + edit: true, + verify: false + }, + community_public_ownership: { + edit: true, + verify: true + }, + community_public_ownership_source: { + edit: true, + verify: false } + }); diff --git a/app/src/frontend/building/data-components/multi-select-data-entry.tsx b/app/src/frontend/building/data-components/multi-select-data-entry.tsx index f1464461..093e1b27 100644 --- a/app/src/frontend/building/data-components/multi-select-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-select-data-entry.tsx @@ -43,7 +43,7 @@ export const MultiSelectDataEntry: React.FunctionComponent void; - user_verified: any; + user_verified: Partial; user?: any; } diff --git a/app/src/frontend/building/data-containers/community.tsx b/app/src/frontend/building/data-containers/community.tsx index e11ddcfb..9ba63093 100644 --- a/app/src/frontend/building/data-containers/community.tsx +++ b/app/src/frontend/building/data-containers/community.tsx @@ -10,6 +10,9 @@ import { LogicalDataEntry } from '../data-components/logical-data-entry/logical- import { buildingUserFields, dataFields } from '../../config/data-fields-config'; import './community.css'; +import SelectDataEntry from '../data-components/select-data-entry'; +import Verification from '../data-components/verification'; +import DataEntry from '../data-components/data-entry'; /** * Community view/edit section @@ -80,35 +83,61 @@ const CommunityView: React.FunctionComponent = (props) => { Can you help add information about public ownership of the building? - {/* + {/* TODO: dates */} + { + // props.building.community_activities === true && + // + //
+ //
+ //
+ //
+ //
+ } + + */} - {/*

{props.intro}

*/} - {/*
    -
  • Is this a publicly owned building?
  • - { - // "slug": "community_publicly_owned", - // "type": "checkbox" - } -
  • Has this building ever been used for community or public services activities?
  • - { - // "slug": "community_past_public", - // "type": "checkbox" - } -
  • Would you describe this building as a community asset?
  • - { - // "slug": "community_asset", - // "type": "checkbox" - } -
*/} + /> + + }; const CommunityContainer = withCopyEdit(CommunityView); diff --git a/app/src/frontend/config/data-fields-config.ts b/app/src/frontend/config/data-fields-config.ts index 3d87ad0d..e220449c 100644 --- a/app/src/frontend/config/data-fields-config.ts +++ b/app/src/frontend/config/data-fields-config.ts @@ -539,19 +539,27 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ } }, - community_publicly_owned: { + community_activities: { category: Category.Community, - title: "Is the building in some form of community ownership?", - example: false + title: "Has the building ever been used for community activities?", + tooltip: "E.g. youth club, place of worship, GP surgery, pub", + example: true }, - community_public_ownership_form: { + // community_activities_dates: { + // category: Category.Community, + // title: "When was this building used for community activities?" + // }, + + + community_public_ownership: { category: Category.Community, - title: "What is the form of community ownership of this building?", - example: "State-owned" + title: "Is the building in public/community ownership?", + example: "Not in public/community ownership" }, + community_public_ownership_source: { category: Category.Community, - title: "Community ownership source links", + title: "Community ownership source link", example: "https://example.com" }, diff --git a/migrations/unreleased/0xx.community.down.sql b/migrations/unreleased/0xx.community.down.sql index 094bde63..05100142 100644 --- a/migrations/unreleased/0xx.community.down.sql +++ b/migrations/unreleased/0xx.community.down.sql @@ -22,4 +22,16 @@ ALTER TABLE building_user_attributes DROP COLUMN IF EXISTS community_local_significance; ALTER TABLE buildings -DROP COLUMN IF EXISTS community_local_significance_total; \ No newline at end of file +DROP COLUMN IF EXISTS community_local_significance_total; + +ALTER TABLE buildings +DROP COLUMN IF EXISTS community_activities; + +ALTER TABLE buildings +DROP COLUMN IF EXISTS community_public_ownership; + +DROP TYPE IF EXISTS public_ownership_type; + +ALTER TABLE buildings +DROP COLUMN IF EXISTS community_public_ownership_source; + diff --git a/migrations/unreleased/0xx.community.up.sql b/migrations/unreleased/0xx.community.up.sql index ca426ae1..245c3172 100644 --- a/migrations/unreleased/0xx.community.up.sql +++ b/migrations/unreleased/0xx.community.up.sql @@ -37,4 +37,22 @@ ALTER TABLE building_user_attributes ADD COLUMN community_local_significance BOOLEAN DEFAULT false; ALTER TABLE buildings -ADD COLUMN community_local_significance_total INT DEFAULT 0; \ No newline at end of file +ADD COLUMN community_local_significance_total INT DEFAULT 0; + +ALTER TABLE buildings +ADD COLUMN community_activities BOOLEAN NULL; + +CREATE TYPE public_ownership_type + AS ENUM ( + 'State-owned', + 'Charity-owned', + 'Community-owned/cooperative', + 'Owned by other non-profit body', + 'Not in public/community ownership' + ); + +ALTER TABLE buildings +ADD COLUMN community_public_ownership public_ownership_type; + +ALTER TABLE buildings +ADD COLUMN community_public_ownership_source VARCHAR; \ No newline at end of file From d438dc218934f44d86a13a6a04735dff769067bb Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 27 Sep 2021 14:07:54 +0300 Subject: [PATCH 08/14] Revert type change for user_verified in frontend In principle the type annotation is desirable, but something causes errors in YearDataEntry. Need to work this out at a different time --- .../frontend/building/data-containers/category-view-props.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/frontend/building/data-containers/category-view-props.ts b/app/src/frontend/building/data-containers/category-view-props.ts index ec71a6bf..27d510f6 100644 --- a/app/src/frontend/building/data-containers/category-view-props.ts +++ b/app/src/frontend/building/data-containers/category-view-props.ts @@ -22,7 +22,7 @@ interface CategoryViewProps { /* Special handler for setting a value and immediately saving */ onSaveChange: (slug: string, value: any) => void; - user_verified: Partial; + user_verified: any; user?: any; } From d3a17f2e5f887561dd4efbbc18e80b7c304123ee Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 30 Sep 2021 15:51:20 +0100 Subject: [PATCH 09/14] Tweak style and text of user opinion aggregations --- .../user-opinion-data-entry.tsx | 31 +++++++++++++------ app/src/frontend/config/data-fields-config.ts | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/frontend/building/data-components/user-opinion-data-entry.tsx b/app/src/frontend/building/data-components/user-opinion-data-entry.tsx index 3efe2d61..d8978d1f 100644 --- a/app/src/frontend/building/data-components/user-opinion-data-entry.tsx +++ b/app/src/frontend/building/data-components/user-opinion-data-entry.tsx @@ -34,15 +34,28 @@ const UserOpinionEntry: React.FunctionComponent = (props) onChange={e => props.onChange(props.slug, e.target.checked)} /> Yes -

- { - (props.aggregateValue)? - (props.aggregateValue === 1)? - `1 person ${props.aggregationDescriptions.one}` - : `${props.aggregateValue} people ${props.aggregationDescriptions.many}` - : `0 people ${props.aggregationDescriptions.zero}` - } -

+
+ +
); }; diff --git a/app/src/frontend/config/data-fields-config.ts b/app/src/frontend/config/data-fields-config.ts index e220449c..3612d5f9 100644 --- a/app/src/frontend/config/data-fields-config.ts +++ b/app/src/frontend/config/data-fields-config.ts @@ -522,7 +522,7 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ example: 100, tooltip: "People who like the building and think it contributes to the city.", aggregationDescriptions: { - zero: "like this building so far", + zero: "like this building", one: "likes this building", many: "like this building" } From a4d1afab81e520d6a504eb8304755786e1f63d59 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Fri, 1 Oct 2021 13:30:03 +0100 Subject: [PATCH 10/14] Rewrite map with hooks, add map colour toggle Map and Legend components rewritten using hooks. Also, each category can now have multiple available colour scales. These can be switched using a select dropdown in the legend. --- app/map_styles/polygon.xml | 62 +++++ .../frontend/config/category-maps-config.ts | 103 ++++++--- app/src/frontend/config/tileserver-config.ts | 2 + app/src/frontend/map-app.tsx | 4 +- app/src/frontend/map/legend.css | 10 + app/src/frontend/map/legend.tsx | 191 ++++++++-------- app/src/frontend/map/map.tsx | 214 +++++++++--------- app/src/tiles/dataDefinition.ts | 21 ++ 8 files changed, 365 insertions(+), 242 deletions(-) diff --git a/app/map_styles/polygon.xml b/app/map_styles/polygon.xml index 62a90a1c..c89b49c2 100644 --- a/app/map_styles/polygon.xml +++ b/app/map_styles/polygon.xml @@ -446,6 +446,68 @@ + +