Replace likes with generic building-user attribs

This commit is contained in:
Maciej Ziarkowski 2021-08-23 02:26:58 +01:00
parent c1679a0c35
commit 29ed25f36c
40 changed files with 747 additions and 522 deletions

67
app/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
export const buildingAttributesConfig = valueType<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
ref_toid: {
edit: false
},
ref_osm_id: {
edit: true,
},
@ -268,5 +277,13 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
}
});
export type Building = { [k in keyof typeof dataFieldsConfig]: any };
export type BuildingUpdate = Partial<Building>;
export const buildingUserAttributesConfig = valueType<DataFieldConfig>()({
community_like: {
perUser: true,
edit: true,
verify: false,
},
});
export const allAttributesConfig = Object.assign({}, buildingAttributesConfig, buildingUserAttributesConfig);

View File

@ -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',

View File

@ -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
};

View File

@ -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<any>) => Promise<number>;
export async function aggregateCountTrue(buildingId: number, attributeName: string, t?: ITask<any>): Promise<number> {
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<AggregationMethod, AggregationMethodFunction> = {
'countTrue': aggregateCountTrue
};

View File

@ -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<any>
) {
): Promise<BaseBuilding> {
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<any>
): Promise<object> {
): Promise<BuildingAttributes> {
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<any>) {
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<any>
) : Promise<BuildingUserAttributes> {
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<any>) {
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<any>
): Promise<BuildingUserAttributes> {
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);
}
}

View File

@ -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<any>): Promise<number> {
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<any>): Promise<void> {
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<any>): Promise<void> {
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");
}
}

View File

@ -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<T>(cb: (t: ITask<any>) => Promise<T>): Promise<T> {
return db.tx({mode: serializableMode}, cb);
}

View File

@ -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<BuildingAttributes>;
userAttributes?: Partial<BuildingUserAttributes>;
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;
}

View File

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

View File

@ -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<Building> = {...baseBuilding};
export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // 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<BuildingUserAttributes> {
return buildingDataAccess.getBuildingUserData(buildingId, userId);
}
export * from './edit';
export * from './history';
export * from './like';
export * from './query';
export * from './uprn';
export * from './verify';

View File

@ -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<BuildingUpdate> {
// 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];
}

View File

@ -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);
},
);
}

View File

@ -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<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
): Promise<object> {
return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t);
}
const update = await getUpdateValue(t);
const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches);
const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) {
throw new UserError('No change provided');
}
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
expireBuildingTileCache(buildingId);
return updatedData;
});
}
/**
* Compare old and new data objects, generate shallow merge patch of changed fields
* - forward patch is object with {keys: new_values}
* - reverse patch is object with {keys: old_values}
*
* @param {object} oldObj
* @param {object} newObj
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (!_.isEqual(oldObj[key], value)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse];
}

View File

@ -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<string, number> = {};
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;

View File

@ -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<any>
): Promise<Partial<BuildingAttributes>> {
const derivedAttributes: Partial<BuildingAttributes> = {};
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);
}

View File

@ -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<any> {
async function processCurrentLandUseClassifications(
buildingId: number,
buildingUpdate: Partial<BuildingAttributes>,
t?: ITask<any>
): Promise<any> {
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<BuildingUpdate> {
async function processDynamicsDemolishedBuildings(
buildingId: number,
attributesUpdate: Partial<BuildingAttributes>,
t?: ITask<any>
): Promise<Partial<BuildingAttributes>> {
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<any> {
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
export async function processBuildingUpdate(buildingId: number, {attributes, userAttributes}: BuildingUpdate, t?: ITask<any>): Promise<BuildingUpdate> {
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
};
}

View File

@ -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);
}
}
}
}

View File

@ -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<BuildingAttributes> | Partial<BuildingUserAttributes>,
isExternal: boolean = true
) {
_.forIn(attributes, (value, fieldKey) => validateFieldChange(fieldKey, value, isExternal));
}

View File

@ -142,9 +142,9 @@ async function getNewUserAPIKey(id: string) {
}
}
async function authAPIUser(key: string) {
async function authAPIUser(key: string): Promise<string> {
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;

View File

@ -15,7 +15,6 @@ hydrate(
<App
user={data.user}
building={data.building}
building_like={data.building_like}
user_verified={data.user_verified}
revisionId={data.latestRevisionId}
/>

View File

@ -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<BuildingAttributes> & Partial<BuildingUserAttributes> & {
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<UpdatedBuilding> {
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
};
}
}

View File

@ -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<Building>(preloadedData);
const [isOld, setIsOld] = useState<boolean>(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) {

View File

@ -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<AppProps> = props => {
<Route exact path={mapAppPaths} >
<MapApp
building={props.building}
building_like={props.building_like}
user_verified={props.user_verified}
revisionId={props.revisionId}
/>

View File

@ -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;
}

View File

@ -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<LikeDataEntryProps> = (props) => {
const data_string = JSON.stringify({like: true});
const fieldName = 'community_like';
return (
<>
<div className="data-title">
<div className="data-title-actions icon-buttons">
<NavLink
to={`/multi-edit/${Category.Community}?data=${data_string}`}
className="icon-button like">
Like more
</NavLink>
<Tooltip text="People who like the building and think it contributes to the city." />
</div>
<label>Like</label>
</div>
<DataTitleCopyable
slug={fieldName}
title={buildingUserFields.community_like.title}
copy={props.copy}
/>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name="like"
checked={!!props.userLike}
checked={!!props.userValue}
disabled={props.mode === 'view'}
onChange={e => props.onLike(e.target.checked)}
/>
I like this building and think it contributes to the city
onChange={e => props.onChange(fieldName, e.target.checked)}
/> Yes
</label>
<p>
{
(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!"
}
</p>

View File

@ -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<Building>;
buildingEdits: BuildingEdits;
}
export type DataContainerType = React.ComponentType<DataContainerProps>;
@ -68,7 +68,6 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => 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<CategoryViewProps>) => 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<CategoryViewProps>) => 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<CategoryViewProps>) => 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<BuildingAttributes>) {
async doSubmit(edits: Partial<Building & BuildingUserAttributes>) {
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<CategoryViewProps>) => DataContaine
<WrappedComponent
intro={this.props.intro}
building={this.props.building}
building_like={this.props.building_like}
mode={this.props.mode}
edited={false}
copy={copy}
onChange={undefined}
onLike={undefined}
onVerify={undefined}
onSaveAdd={undefined}
onSaveChange={undefined}
@ -404,12 +369,10 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
<WrappedComponent
intro={this.props.intro}
building={currentBuilding}
building_like={this.props.building_like}
mode={this.props.mode}
edited={edited}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
onVerify={this.handleVerify}
onSaveAdd={this.handleSaveAdd}
onSaveChange={this.handleSaveChange}

View File

@ -10,12 +10,10 @@ interface CopyProps {
interface CategoryViewProps {
intro: string;
building: Building;
building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit';
edited: boolean;
copy: CopyProps;
onChange: (key: string, value: any) => 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 */

View File

@ -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<CategoryViewProps> = (props) => (
<Fragment>
<>
<InfoBox>
Can you help add information on how well you think the building works, and on if it is in public ownership?
</InfoBox>
<LikeDataEntry
userLike={props.building_like}
totalLikes={props.building.likes_total}
userValue={props.building.community_like}
aggregateValue={props.building.likes_total}
onChange={props.onSaveChange}
mode={props.mode}
onLike={props.onLike}
/>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
copy={props.copy}
/>
{/*
<LogicalDataEntry
slug='community_publicly_owned'
title={dataFields.community_publicly_owned.title}
value={props.building.community_publicly_owned}
disallowFalse={props.building.community_public_ownership_form != null}
disallowNull={props.building.community_public_ownership_form != null}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/> */}
{/* <p className="data-intro">{props.intro}</p> */}
{/* <ul className="data-list">
<li>Is this a publicly owned building?</li>
{
// "slug": "community_publicly_owned",
@ -33,8 +53,8 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
// "slug": "community_asset",
// "type": "checkbox"
}
</ul>
</Fragment>
</ul> */}
</>
);
const CommunityContainer = withCopyEdit(CommunityView);

View File

@ -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<MultiEditProps> = (props) => {
const [data, error] = useMultiEditData();
const isLike = props.category === Category.Community;
return (
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">{
isLike ?
<>Like Me!</> :
<>Copy {props.category} data</>
}</h2>
<h2 className="h2">Paste {props.category} data</h2>
</header>
<div className="section-body">
<form>
{
error ?
<ErrorBox msg={error} /> :
<InfoBox msg={
isLike ?
'Click all the buildings that you like and think contribute to the city!' :
'Click buildings one at a time to colour using the data below'
} />
<InfoBox msg='Click buildings one at a time to colour using the data below' />
}
{
!isLike && data &&
data &&
Object.keys(data).map((key => (
<DataEntry
key={key}
title={dataFields[key]?.title ?? `Unknown field (${key})`}
title={allFieldsConfig[key]?.title ?? `Unknown field (${key})`}
slug={key}
disabled={true}
value={data[key]}

View File

@ -45,8 +45,25 @@ export interface DataFieldDefinition {
* E.g. for building attachment form, you could use "Detached" as example
*/
example: any;
/**
* Whether the field is a field that has an independent value for each user.
* For example, user building likes are one of such fields.
* By default this is false - fields are treated as not user-specific.
*/
perUser?: boolean;
}
export const buildingUserFields = {
community_like: {
perUser: true,
category: Category.Community,
title: "Do you like this building and think it contributes to the city?",
example: true,
},
};
export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
location_name: {
category: Category.Location,
@ -444,6 +461,9 @@ 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."
},
},
dynamics_has_demolished_buildings: {
@ -486,3 +506,5 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
]
}
};
export const allFieldsConfig = {...dataFields, ...buildingUserFields};

View File

@ -2,14 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import loadable from '@loadable/component';
import { useRevisionId } from './hooks/use-revision';
import { useBuildingData } from './hooks/use-building-data';
import { useBuildingLikeData } from './hooks/use-building-like-data';
import { useUserVerifiedData } from './hooks/use-user-verified-data';
import { useRevisionId } from './api-data/use-revision';
import { useBuildingData } from './api-data/use-building-data';
import { useUserVerifiedData } from './api-data/use-user-verified-data';
import { useUrlBuildingParam } from './nav/use-url-building-param';
import { useUrlCategoryParam } from './nav/use-url-category-param';
import { useUrlModeParam } from './nav/use-url-mode-param';
import { apiPost } from './apiHelpers';
import BuildingView from './building/building-view';
import Categories from './building/categories';
import { EditHistory } from './building/edit-history/edit-history';
@ -22,6 +20,8 @@ import { useLastNotEmpty } from './hooks/use-last-not-empty';
import { Category } from './config/categories-config';
import { defaultMapCategory } from './config/category-maps-config';
import { useMultiEditData } from './hooks/use-multi-edit-data';
import { useAuth } from './auth-context';
import { sendBuildingUpdate } from './api-data/building-update';
/**
* Load and render ColouringMap component on client-side only.
@ -41,7 +41,6 @@ const ColouringMap = loadable(
interface MapAppProps {
building?: Building;
building_like?: boolean;
revisionId?: string;
user_verified?: object;
}
@ -61,6 +60,7 @@ function setOrToggle<T>(currentValue: T, newValue: T): T {
}
export const MapApp: React.FC<MapAppProps> = props => {
const { user } = useAuth();
const [categoryUrlParam] = useUrlCategoryParam();
const [currentCategory, setCategory] = useState<Category>();
@ -70,8 +70,7 @@ export const MapApp: React.FC<MapAppProps> = 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<MapAppProps> = 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<MapAppProps> = 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<MapAppProps> = props => {
mode={viewEditMode}
cat={displayCategory}
building={building}
building_like={buildingLike}
user_verified={userVerified ?? {}}
onBuildingUpdate={handleBuildingUpdate}
onBuildingLikeUpdate={handleBuildingLikeUpdate}
onUserVerifiedUpdate={handleUserVerifiedUpdate}
/>
</Route>

View File

@ -1,4 +1,6 @@
import { dataFields } from '../config/data-fields-config';
import { buildingUserFields, dataFields } from '../config/data-fields-config';
type AttributesBasedOnExample<T extends Record<string, {example: any}>> = {[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<typeof dataFields>;
export type BuildingUserAttributes = AttributesBasedOnExample<typeof buildingUserFields>;
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<BuildingAttributes & BuildingUserAttributes>;

View File

@ -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) {
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
user_verified={data.userVerified}
/>

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS building_user_attributes;

View File

@ -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;