Merge branch 'develop' into feature/data-container-state

This commit is contained in:
mz8i 2019-10-29 17:34:27 +00:00 committed by GitHub
commit cf18e5bb70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 667 additions and 557 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,147 +1,148 @@
import express from 'express';
import * as buildingService from '../services/building';
import * as userService from '../services/user';
import asyncController from '../routes/asyncController';
// GET buildings
// not implemented - may be useful to GET all buildings, paginated
// GET buildings at point
function getBuildingsByLocation(req, res) {
const getBuildingsByLocation = asyncController(async (req: express.Request, res: express.Response) => {
const { lng, lat } = req.query;
buildingService.queryBuildingsAtPoint(lng, lat).then(function (result) {
try {
const result = await buildingService.queryBuildingsAtPoint(lng, lat);
res.send(result);
}).catch(function (error) {
} catch (error) {
console.error(error);
res.send({ error: 'Database error' })
})
res.send({ error: 'Database error' });
}
});
// GET buildings by reference (UPRN/TOID or other identifier)
function getBuildingsByReference(req, res) {
const getBuildingsByReference = asyncController(async (req: express.Request, res: express.Response) => {
const { key, id } = req.query;
buildingService.queryBuildingsByReference(key, id).then(function (result) {
try {
const result = await buildingService.queryBuildingsByReference(key, id);
res.send(result);
}).catch(function (error) {
} catch (error) {
console.error(error);
res.send({ error: 'Database error' })
})
res.send({ error: 'Database error' });
}
});
// GET individual building, POST building updates
function getBuildingById(req, res) {
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
buildingService.getBuildingById(building_id).then(function (result) {
try {
const result = await buildingService.getBuildingById(building_id);
res.send(result);
}).catch(function (error) {
} catch(error) {
console.error(error);
res.send({ error: 'Database error' })
})
res.send({ error: 'Database error' });
}
});
function updateBuildingById(req, res) {
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
if (req.session.user_id) {
updateBuilding(req, res, req.session.user_id);
await updateBuilding(req, res, req.session.user_id);
} else if (req.query.api_key) {
userService.authAPIUser(req.query.api_key)
.then(function (user) {
updateBuilding(req, res, user.user_id)
})
.catch(function (err) {
try {
const user = await userService.authAPIUser(req.query.api_key);
await updateBuilding(req, res, user.user_id);
} catch(err) {
console.error(err);
res.send({ error: 'Must be logged in' });
});
}
} else {
res.send({ error: 'Must be logged in' });
}
}
});
function updateBuilding(req, res, userId) {
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
const { building_id } = req.params;
const building = req.body;
buildingService.saveBuilding(building_id, building, userId).then(building => {
if (building.error) {
res.send(building)
return
}
const buildingUpdate = req.body;
try {
const building = await buildingService.saveBuilding(building_id, buildingUpdate, userId);
if (typeof (building) === 'undefined') {
res.send({ error: 'Database error' })
return
return res.send({ error: 'Database error' });
}
if (building.error) {
return res.send(building);
}
res.send(building);
} catch(err) {
res.send({ error: 'Database error' });
}
res.send(building)
}).catch(
() => res.send({ error: 'Database error' })
)
}
// GET building UPRNs
function getBuildingUPRNsById(req, res) {
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
buildingService.getBuildingUPRNsById(building_id).then(function (result) {
try {
const result = await buildingService.getBuildingUPRNsById(building_id);
if (typeof (result) === 'undefined') {
res.send({ error: 'Database error' })
return
return res.send({ error: 'Database error' });
}
res.send({
uprns: result
});
}).catch(function (error) {
res.send({uprns: result});
} catch(error) {
console.error(error);
res.send({ error: 'Database error' })
})
res.send({ error: 'Database error' });
}
});
// GET/POST like building
function getBuildingLikeById(req, res) {
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
res.send({ like: false }); // not logged in, so cannot have liked
return
return res.send({ like: false }); // not logged in, so cannot have liked
}
const { building_id } = req.params;
buildingService.getBuildingLikeById(building_id, req.session.user_id).then(like => {
try {
const like = await buildingService.getBuildingLikeById(building_id, req.session.user_id);
// any value returned means like
res.send({ like: like })
}).catch(
() => res.send({ error: 'Database error' })
)
res.send({ like: like });
} catch(error) {
res.send({ error: 'Database error' })
}
});
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
function updateBuildingLikeById(req, res) {
if (!req.session.user_id) {
res.send({ error: 'Must be logged in' });
return
}
const { building_id } = req.params;
const { like } = req.body;
if (like) {
buildingService.likeBuilding(building_id, req.session.user_id).then(building => {
try {
const building = like ?
await buildingService.likeBuilding(building_id, req.session.user_id) :
await buildingService.unlikeBuilding(building_id, req.session.user_id);
if (building.error) {
res.send(building)
return
return res.send(building);
}
if (typeof (building) === 'undefined') {
res.send({ error: 'Database error' })
return
return res.send({ error: 'Database error' });
}
res.send(building)
}).catch(
() => res.send({ error: 'Database error' })
)
} else {
buildingService.unlikeBuilding(building_id, req.session.user_id).then(building => {
if (building.error) {
res.send(building)
return
}
if (typeof (building) === 'undefined') {
res.send({ error: 'Database error' })
return
}
res.send(building)
}).catch(
() => res.send({ error: 'Database error' })
)
res.send(building);
} catch(error) {
res.send({ error: 'Database error' });
}
});
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
try {
const revisionId = await buildingService.getLatestRevisionId();
res.send({latestRevisionId: revisionId});
} catch(error) {
res.send({ error: 'Database error' });
}
});
export default {
getBuildingsByLocation,
@ -150,5 +151,6 @@ export default {
updateBuildingById,
getBuildingUPRNsById,
getBuildingLikeById,
updateBuildingLikeById
updateBuildingLikeById,
getLatestRevisionId
};

View File

@ -8,23 +8,22 @@ import { TokenVerificationError } from '../services/passwordReset';
import asyncController from '../routes/asyncController';
import { ValidationError } from '../validation';
function createUser(req, res) {
const createUser = asyncController(async (req: express.Request, res: express.Response) => {
const user = req.body;
if (req.session.user_id) {
res.send({ error: 'Already signed in' });
return;
return res.send({ error: 'Already signed in' });
}
if (user.email) {
if (user.email != user.confirm_email) {
res.send({ error: 'Email did not match confirmation.' });
return;
return res.send({ error: 'Email did not match confirmation.' });
}
} else {
user.email = null;
}
userService.createUser(user).then(function (result) {
try {
const result = await userService.createUser(user);
if (result.user_id) {
req.session.user_id = result.user_id;
res.send({ user_id: result.user_id });
@ -32,39 +31,40 @@ function createUser(req, res) {
req.session.user_id = undefined;
res.send({ error: result.error });
}
}).catch(function (err) {
} catch(err) {
console.error(err);
res.send(err);
});
}
});
function getCurrentUser(req, res) {
const getCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
res.send({ error: 'Must be logged in' });
return;
return res.send({ error: 'Must be logged in' });
}
userService.getUserById(req.session.user_id).then(function (user) {
try {
const user = await userService.getUserById(req.session.user_id);
res.send(user);
}).catch(function (error) {
} catch(error) {
res.send(error);
});
}
});
function deleteCurrentUser(req, res) {
const deleteCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
console.log(`Deleting user ${req.session.user_id}`);
userService.deleteUser(req.session.user_id).then(
() => userService.logout(req.session)
).then(() => {
try {
await userService.deleteUser(req.session.user_id);
await userService.logout(req.session);
res.send({ success: true });
}).catch(err => {
} catch(err) {
res.send({ error: err });
});
}
});
const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {

View File

@ -14,6 +14,8 @@ router.get('/locate', buildingController.getBuildingsByLocation);
// GET buildings by reference (UPRN/TOID or other identifier)
router.get('/reference', buildingController.getBuildingsByReference);
router.get('/revision', buildingController.getLatestRevisionId);
router.route('/:building_id.json')
// GET individual building
.get(buildingController.getBuildingById)

View File

@ -5,6 +5,7 @@
import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
import { ITask } from 'pg-promise';
// 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.
@ -18,8 +19,22 @@ const serializable = new TransactionMode({
readOnly: false
});
function queryBuildingsAtPoint(lng, lat) {
return db.manyOrNone(
async function getLatestRevisionId() {
try {
const data = await db.oneOrNone(
`SELECT MAX(log_id) from logs`
);
return data == undefined ? undefined : data.max;
} catch(err) {
console.error(err);
return undefined;
}
}
async function queryBuildingsAtPoint(lng: number, lat: number) {
try {
return await db.manyOrNone(
`SELECT b.*
FROM buildings as b, geometries as g
WHERE
@ -34,15 +49,17 @@ function queryBuildingsAtPoint(lng, lat) {
)
`,
[lng, lat]
).catch(function (error) {
);
} catch(error) {
console.error(error);
return undefined;
});
}
}
function queryBuildingsByReference(key, id) {
async function queryBuildingsByReference(key: string, ref: string) {
try {
if (key === 'toid') {
return db.manyOrNone(
return await db.manyOrNone(
`SELECT
*
FROM
@ -50,14 +67,10 @@ function queryBuildingsByReference(key, id) {
WHERE
ref_toid = $1
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
if (key === 'uprn') {
return db.manyOrNone(
[ref]
);
} else if (key === 'uprn') {
return await db.manyOrNone(
`SELECT
b.*
FROM
@ -67,90 +80,208 @@ function queryBuildingsByReference(key, id) {
AND
p.uprn = $1
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
[ref]
);
} else {
return { error: 'Key must be UPRN or TOID' };
}
} catch(err) {
console.error(err);
return undefined;
}
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
}
function getBuildingById(id) {
return db.one(
async function getBuildingById(id: number) {
try {
const building = await db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
).then((building) => {
return getBuildingEditHistory(id).then((edit_history) => {
building.edit_history = edit_history
return building
})
}).catch(function (error) {
);
building.edit_history = await getBuildingEditHistory(id);
return building;
} catch(error) {
console.error(error);
return undefined;
});
}
}
function getBuildingEditHistory(id) {
return db.manyOrNone(
async function getBuildingEditHistory(id: number) {
try {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id`,
[id]
).then((data) => {
return data
}).catch(function (error) {
);
} catch(error) {
console.error(error);
return []
});
return [];
}
}
function getBuildingLikeById(buildingId, userId) {
return db.oneOrNone(
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]
).then(res => {
return res && res.like
}).catch(function (error) {
);
return res && res.like;
} catch(error) {
console.error(error);
return undefined;
});
}
}
function getBuildingUPRNsById(id) {
return db.any(
async function getBuildingUPRNsById(id: number) {
try {
return await db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id]
).catch(function (error) {
);
} catch(error) {
console.error(error);
return undefined;
});
}
}
function saveBuilding(buildingId, building, userId) {
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
try {
return await updateBuildingData(buildingId, userId, async () => {
// remove read-only fields from consideration
delete building.building_id;
delete building.revision_id;
delete building.geometry_id;
// start transaction around save operation
// - select and compare to identify changeset
// - insert changeset
// - update to latest state
// commit or rollback (repeated-read sufficient? or serializable?)
return db.tx(t => {
// return whitelisted fields to update
return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
});
} catch(error) {
console.error(error);
return { error: error };
}
}
async function likeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
},
async (t) => {
// insert building-user like
await t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
);
},
);
} catch (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined;
}
}
}
async function unlikeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
},
async (t) => {
// remove building-user like
const result = await t.result(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
);
if (result.rowCount === 0) {
throw new Error('No change');
}
},
);
} catch(error) {
console.error(error);
if (error.message === 'No change') {
// 'No change' is thrown if user doesn't like this building
return { error: 'It looks like you have already revoked your like for that building!' };
} else {
return undefined;
}
}
}
// === Utility functions ===
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
const subObject = {};
for (let [key, value] of Object.entries(obj)) {
if(fieldWhitelist.has(key)) {
subObject[key] = value;
}
}
return subObject;
}
/**
*
* @param buildingId ID of the building to count likes for
* @param t The database context inside which the count should happen
*/
function getBuildingLikeCount(buildingId: number, t: ITask<unknown>) {
return t.one(
'SELECT count(*) as likes_total FROM building_user_likes WHERE building_id = $1;',
[buildingId]
);
}
/**
* 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)
*/
async function updateBuildingData(
buildingId: number,
userId: string,
getUpdateValue: (t: ITask<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
) {
return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t);
}
const update = await getUpdateValue(t);
const oldBuilding = await t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
).then(oldBuilding => {
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches)
const forward = patches[0];
const reverse = patches[1];
const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) {
return Promise.reject('No change provided')
throw 'No change provided';
}
return t.one(
const revision = await t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
@ -158,10 +289,12 @@ function saveBuilding(buildingId, building, userId) {
) RETURNING log_id
`,
[forward, reverse, buildingId, userId]
).then(revision => {
);
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets)
return t.one(
console.log('Setting', buildingId, sets);
const data = await t.one(
`UPDATE
buildings
SET
@ -173,127 +306,15 @@ function saveBuilding(buildingId, building, userId) {
*
`,
[revision.log_id, sets, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
});
});
}).catch(function (error) {
console.error(error);
return { error: error };
);
expireBuildingTileCache(buildingId);
return data;
});
}
function likeBuilding(buildingId, userId) {
// start transaction around save operation
// - insert building-user like
// - count total likes
// - insert changeset
// - update building to latest state
// commit or rollback (serializable - could be more compact?)
return db.tx({mode: serializable}, t => {
return t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
).then(() => {
return t.one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
).then(building => {
return t.one(
`INSERT INTO logs (
forward_patch, building_id, user_id
) VALUES (
$1:json, $2, $3
) RETURNING log_id
`,
[{ likes_total: building.likes }, buildingId, userId]
).then(revision => {
return t.one(
`UPDATE buildings
SET
revision_id = $1,
likes_total = $2
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, building.likes, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
})
});
});
}).catch(function (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined
}
});
}
function unlikeBuilding(buildingId, userId) {
// start transaction around save operation
// - insert building-user like
// - count total likes
// - insert changeset
// - update building to latest state
// commit or rollback (serializable - could be more compact?)
return db.tx({mode: serializable}, t => {
return t.none(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
).then(() => {
return t.one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
).then(building => {
return t.one(
`INSERT INTO logs (
forward_patch, building_id, user_id
) VALUES (
$1:json, $2, $3
) RETURNING log_id
`,
[{ likes_total: building.likes }, buildingId, userId]
).then(revision => {
return t.one(
`UPDATE buildings
SET
revision_id = $1,
likes_total = $2
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, building.likes, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
})
});
});
}).catch(function (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined
}
});
}
function privateQueryBuildingBBOX(buildingId){
function privateQueryBuildingBBOX(buildingId: number){
return db.one(
`SELECT
ST_XMin(envelope) as xmin,
@ -310,14 +331,13 @@ function privateQueryBuildingBBOX(buildingId){
b.building_id = $1
) as envelope`,
[buildingId]
)
);
}
function expireBuildingTileCache(buildingId) {
privateQueryBuildingBBOX(buildingId).then((bbox) => {
async function expireBuildingTileCache(buildingId: number) {
const bbox = await privateQueryBuildingBBOX(buildingId)
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox);
})
}
const BUILDING_FIELD_WHITELIST = new Set([
@ -385,16 +405,16 @@ const BUILDING_FIELD_WHITELIST = new Set([
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj, newObj, whitelist) {
const reverse = {}
const forward = {}
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (oldObj[key] !== value && whitelist.has(key)) {
if (oldObj[key] != value) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse]
return [forward, reverse];
}
export {
@ -405,5 +425,6 @@ export {
getBuildingUPRNsById,
saveBuilding,
likeBuilding,
unlikeBuilding
unlikeBuilding,
getLatestRevisionId
};

View File

@ -6,18 +6,21 @@ import { errors } from 'pg-promise';
import db from '../../db';
import { validateUsername, ValidationError, validatePassword } from '../validation';
import { promisify } from 'util';
function createUser(user) {
async function createUser(user) {
try {
validateUsername(user.username);
validatePassword(user.password);
} catch(err) {
if (err instanceof ValidationError) {
return Promise.reject({ error: err.message });
throw { error: err.message };
} else throw err;
}
return db.one(
try {
return await db.one(
`INSERT
INTO users (
user_id,
@ -35,22 +38,24 @@ function createUser(user) {
user.email,
user.password
]
).catch(function (error) {
console.error('Error:', error)
);
} catch(error) {
console.error('Error:', error);
if (error.detail.indexOf('already exists') !== -1) {
if (error.detail.indexOf('username') !== -1) {
if (error.detail.includes('already exists')) {
if (error.detail.includes('username')) {
return { error: 'Username already registered' };
} else if (error.detail.indexOf('email') !== -1) {
} else if (error.detail.includes('email')) {
return { error: 'Email already registered' };
}
}
return { error: 'Database error' }
});
return { error: 'Database error' };
}
}
function authUser(username, password) {
return db.one(
async function authUser(username: string, password: string) {
try {
const user = await db.one(
`SELECT
user_id,
(
@ -63,24 +68,26 @@ function authUser(username, password) {
username,
password
]
).then(function (user) {
);
if (user && user.auth_ok) {
return { user_id: user.user_id }
} else {
return { error: 'Username or password not recognised' }
}
}).catch(function (err) {
} catch(err) {
if (err instanceof errors.QueryResultError) {
console.error(`Authentication failed for user ${username}`);
return { error: 'Username or password not recognised' };
}
console.error('Error:', err);
return { error: 'Database error' };
})
}
}
function getUserById(id) {
return db.one(
async function getUserById(id: string) {
try {
return await db.one(
`SELECT
username, email, registered, api_key
FROM
@ -90,13 +97,15 @@ function getUserById(id) {
`, [
id
]
).catch(function (error) {
);
} catch(error) {
console.error('Error:', error)
return undefined;
});
}
}
function getUserByEmail(email: string) {
async function getUserByEmail(email: string) {
try {
return db.one(
`SELECT
user_id, username, email
@ -105,13 +114,15 @@ function getUserByEmail(email: string) {
WHERE
email = $1
`, [email]
).catch(function(error) {
);
} catch(error) {
console.error('Error:', error);
return undefined;
});
}
}
function getNewUserAPIKey(id) {
async function getNewUserAPIKey(id: string) {
try{
return db.one(
`UPDATE
users
@ -124,14 +135,16 @@ function getNewUserAPIKey(id) {
`, [
id
]
).catch(function (error) {
);
} catch(error) {
console.error('Error:', error)
return { error: 'Failed to generate new API key.' };
});
}
}
function authAPIUser(key) {
return db.one(
async function authAPIUser(key: string) {
try {
return await db.one(
`SELECT
user_id
FROM
@ -141,14 +154,16 @@ function authAPIUser(key) {
`, [
key
]
).catch(function (error) {
);
} catch(error) {
console.error('Error:', error)
return undefined;
});
}
}
function deleteUser(id) {
return db.none(
async function deleteUser(id: string) {
try {
return await db.none(
`UPDATE users
SET
email = null,
@ -159,20 +174,17 @@ function deleteUser(id) {
deleted_on = now() at time zone 'utc'
WHERE user_id = $1
`, [id]
).catch((error) => {
);
} catch(error) {
console.error('Error:', error);
return {error: 'Database error'};
});
}
}
function logout(session: Express.Session) {
return new Promise((resolve, reject) => {
function logout(session: Express.Session): Promise<void> {
session.user_id = undefined;
session.destroy(err => {
if (err) return reject(err);
return resolve();
});
});
return promisify(session.destroy.bind(session))();
}
export {

View File

@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
hydrate(
<BrowserRouter>
<App user={data.user} building={data.building} building_like={data.building_like} />
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
/>
</BrowserRouter>,
document.getElementById('root')
);

View File

@ -9,7 +9,7 @@ describe('<App />', () => {
const div = document.createElement('div');
ReactDOM.render(
<MemoryRouter>
<App />
<App revisionId={0} />
</MemoryRouter>,
div
);

View File

@ -28,6 +28,7 @@ interface AppProps {
user?: any;
building?: any;
building_like?: boolean;
revisionId: number;
}
/**
@ -49,6 +50,8 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
building_like: PropTypes.bool
};
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?'];
constructor(props: Readonly<AppProps>) {
super(props);
@ -79,7 +82,14 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
render() {
return (
<Fragment>
<Header user={this.state.user} />
<Switch>
<Route exact path={App.mapAppPaths}>
<Header user={this.state.user} animateLogo={false} />
</Route>
<Route>
<Header user={this.state.user} animateLogo={true} />
</Route>
</Switch>
<main>
<Switch>
<Route exact path="/about.html" component={AboutPage} />
@ -105,12 +115,13 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
<Route exact path="/data-accuracy.html" component={DataAccuracyPage} />
<Route exact path="/data-extracts.html" component={DataExtracts} />
<Route exact path="/contact.html" component={ContactPage} />
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category?/:building(\\d+)?"]} render={(props) => (
<Route exact path={App.mapAppPaths} render={(props) => (
<MapApp
{...props}
building={this.props.building}
building_like={this.props.building_like}
user={this.state.user}
revisionId={this.props.revisionId}
/>
)} />
<Route component={NotFound} />

View File

@ -114,22 +114,6 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1}
disabled={true}
/>
<SelectDataEntry
title="Configuration (semi/detached, end/terrace)"
slug="size_configuration"
value={props.building.size_configuration}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
options={[
"Detached",
"Semi-detached",
"Terrace",
"End terrace",
"Block"
]}
/>
<SelectDataEntry
title="Roof shape"
slug="size_roof_shape"

View File

@ -26,22 +26,22 @@ const Logo: React.FunctionComponent<LogoProps> = (props) => {
const LogoGrid: React.FunctionComponent = () => (
<div className="grid">
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-location"></div>
<div className="cell background-use"></div>
<div className="cell background-type"></div>
<div className="cell background-age"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-size"></div>
<div className="cell background-construction"></div>
<div className="cell background-streetscape"></div>
<div className="cell background-team"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-sustainability"></div>
<div className="cell background-community"></div>
<div className="cell background-planning"></div>
<div className="cell background-like"></div>
</div>
</div>
)

View File

@ -5,14 +5,25 @@ import PropTypes from 'prop-types';
import { Logo } from './components/logo';
import './header.css';
interface HeaderProps {
user: any;
animateLogo: boolean;
}
interface HeaderState {
collapseMenu: boolean;
}
/**
* Render the main header using a responsive design
*/
class Header extends React.Component<any, any> { // TODO: add proper types
class Header extends React.Component<HeaderProps, HeaderState> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
user: PropTypes.shape({
username: PropTypes.string
})
}),
animateLogo: PropTypes.bool
};
constructor(props) {
@ -40,7 +51,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
<nav className="navbar navbar-light navbar-expand-lg">
<span className="navbar-brand align-self-start">
<NavLink to="/">
<Logo variant='animated'/>
<Logo variant={this.props.animateLogo ? 'animated' : 'default'}/>
</NavLink>
</span>
<button className="navbar-toggler navbar-toggler-right" type="button"

View File

@ -21,6 +21,7 @@ interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: Building;
building_like: boolean;
user: any;
revisionId: number;
}
interface MapAppState {
@ -41,12 +42,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
constructor(props: Readonly<MapAppProps>) {
super(props);
// set building revision id, default 0
const rev = props.building != undefined ? +props.building.revision_id : 0;
this.state = {
category: this.getCategory(props.match.params.category),
revision_id: rev,
revision_id: props.revisionId || 0,
building: props.building,
building_like: props.building_like
};
@ -63,6 +61,27 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
}
componentDidMount() {
this.fetchLatestRevision();
}
async fetchLatestRevision() {
try {
const res = await fetch(`/api/buildings/revision`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const data = await res.json();
this.increaseRevision(data.latestRevisionId);
} catch(error) {
console.error(error);
}
}
getCategory(category: string) {
if (category === 'categories') return undefined;

View File

@ -1,14 +1,13 @@
import { LatLngExpression } from 'leaflet';
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
import { Map, TileLayer, ZoomControl, AttributionControl, GeoJSON } from 'react-leaflet-universal';
import { GeoJsonObject } from 'geojson';
import '../../../node_modules/leaflet/dist/leaflet.css'
import './map.css'
import { HelpIcon } from '../components/icons';
import Legend from './legend';
import { parseCategoryURL } from '../../parse';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import { Building } from '../models/building';
@ -29,6 +28,7 @@ interface ColouringMapState {
lat: number;
lng: number;
zoom: number;
boundary: GeoJsonObject;
}
/**
* Map area
@ -49,7 +49,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
theme: 'night',
lat: 51.5245255,
lng: -0.1338422,
zoom: 16
zoom: 16,
boundary: undefined,
};
this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this);
@ -101,8 +102,20 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
this.setState({theme: newTheme});
}
async getBoundary() {
const res = await fetch('/geometries/boundary-detailed.geojson');
const data = await res.json() as GeoJsonObject;
this.setState({
boundary: data
});
}
componentDidMount() {
this.getBoundary();
}
render() {
const position: LatLngExpression = [this.state.lat, this.state.lng];
const position: [number, number] = [this.state.lat, this.state.lng];
// baselayer
const key = OS_API_KEY;
@ -118,6 +131,11 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
const boundaryLayer = this.state.boundary &&
<GeoJSON data={this.state.boundary} style={boundaryStyleFn}/>;
// colour-data tiles
const cat = this.props.category;
const tilesetByCat = {
@ -167,6 +185,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
>
{ baseLayer }
{ buildingBaseLayer }
{ boundaryLayer }
{ dataLayer }
{ highlightLayer }
<ZoomControl position="topright" />

View File

@ -57,38 +57,38 @@
* Category colours
*/
.background-location {
background-color: #edc40b;
background-color: #f7c625;
}
.background-use {
background-color: #f0ee0c;
background-color: #f7ec25;
}
.background-type {
background-color: #ff9100;
background-color: #f77d11;
}
.background-age {
background-color: #ee5f63;
background-color: #ff6161;
}
.background-size {
background-color: #ee91bf;
background-color: #f2a2b9;
}
.background-construction {
background-color: #aa7fa7;
background-color: #ab8fb0;
}
.background-streetscape {
background-color: #6f879c;
background-color: #718899;
}
.background-team {
background-color: #5ec232;
background-color: #7cbf39;
}
.background-sustainability {
background-color: #6dbb8b;
background-color: #57c28e;
}
.background-community {
background-color: #65b7ff;
background-color: #6bb1e3;
}
.background-planning {
background-color: #a1a3a9;
background-color: #aaaaaa;
}
.background-like {
background-color: #9c896d;
background-color: #a3916f;
}

View File

@ -12,7 +12,8 @@ import { getUserById } from './api/services/user';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById
getBuildingUPRNsById,
getLatestRevisionId
} from './api/services/building';
@ -36,8 +37,9 @@ function frontendRoute(req: express.Request, res: express.Response) {
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
]).then(function ([user, building, uprns, buildingLike]) {
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
getLatestRevisionId()
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) {
if (isBuilding && typeof (building) === 'undefined') {
context.status = 404;
}
@ -47,12 +49,14 @@ function frontendRoute(req: express.Request, res: express.Response) {
if (data.building != null) {
data.building.uprns = uprns;
}
data.latestRevisionId = latestRevisionId;
renderHTML(context, data, req, res);
}).catch(error => {
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
data.latestRevisionId = 0;
context.status = 500;
renderHTML(context, data, req, res);
});
@ -61,7 +65,12 @@ function frontendRoute(req: express.Request, res: express.Response) {
function renderHTML(context, data, req, res) {
const markup = renderToString(
<StaticRouter context={context} location={req.url}>
<App user={data.user} building={data.building} building_like={data.building_like} />
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
/>
</StaticRouter>
);