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

View File

@ -8,23 +8,22 @@ import { TokenVerificationError } from '../services/passwordReset';
import asyncController from '../routes/asyncController'; import asyncController from '../routes/asyncController';
import { ValidationError } from '../validation'; import { ValidationError } from '../validation';
function createUser(req, res) { const createUser = asyncController(async (req: express.Request, res: express.Response) => {
const user = req.body; const user = req.body;
if (req.session.user_id) { if (req.session.user_id) {
res.send({ error: 'Already signed in' }); return res.send({ error: 'Already signed in' });
return;
} }
if (user.email) { if (user.email) {
if (user.email != user.confirm_email) { if (user.email != user.confirm_email) {
res.send({ error: 'Email did not match confirmation.' }); return res.send({ error: 'Email did not match confirmation.' });
return;
} }
} else { } else {
user.email = null; user.email = null;
} }
userService.createUser(user).then(function (result) { try {
const result = await userService.createUser(user);
if (result.user_id) { if (result.user_id) {
req.session.user_id = result.user_id; req.session.user_id = result.user_id;
res.send({ 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; req.session.user_id = undefined;
res.send({ error: result.error }); res.send({ error: result.error });
} }
}).catch(function (err) { } catch(err) {
console.error(err); console.error(err);
res.send(err); res.send(err);
}); }
} });
function getCurrentUser(req, res) { const getCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) { if (!req.session.user_id) {
res.send({ error: 'Must be logged in' }); return res.send({ error: 'Must be logged in' });
return;
} }
userService.getUserById(req.session.user_id).then(function (user) { try {
const user = await userService.getUserById(req.session.user_id);
res.send(user); res.send(user);
}).catch(function (error) { } catch(error) {
res.send(error); res.send(error);
}); }
} });
function deleteCurrentUser(req, res) { const deleteCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) { if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' }); return res.send({ error: 'Must be logged in' });
} }
console.log(`Deleting user ${req.session.user_id}`); console.log(`Deleting user ${req.session.user_id}`);
userService.deleteUser(req.session.user_id).then( try {
() => userService.logout(req.session) await userService.deleteUser(req.session.user_id);
).then(() => { await userService.logout(req.session);
res.send({ success: true }); res.send({ success: true });
}).catch(err => { } catch(err) {
res.send({ error: err }); res.send({ error: err });
}); }
} });
const resetPassword = asyncController(async function(req: express.Request, res: express.Response) { const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) { 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) // GET buildings by reference (UPRN/TOID or other identifier)
router.get('/reference', buildingController.getBuildingsByReference); router.get('/reference', buildingController.getBuildingsByReference);
router.get('/revision', buildingController.getLatestRevisionId);
router.route('/:building_id.json') router.route('/:building_id.json')
// GET individual building // GET individual building
.get(buildingController.getBuildingById) .get(buildingController.getBuildingById)

View File

@ -5,6 +5,7 @@
import db from '../../db'; import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition'; import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types'; 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 // 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. // JavaScript numerics are 64-bit double, giving only partial coverage.
@ -18,8 +19,22 @@ const serializable = new TransactionMode({
readOnly: false readOnly: false
}); });
function queryBuildingsAtPoint(lng, lat) { async function getLatestRevisionId() {
return db.manyOrNone( 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.* `SELECT b.*
FROM buildings as b, geometries as g FROM buildings as b, geometries as g
WHERE WHERE
@ -34,15 +49,17 @@ function queryBuildingsAtPoint(lng, lat) {
) )
`, `,
[lng, lat] [lng, lat]
).catch(function (error) { );
} catch(error) {
console.error(error); console.error(error);
return undefined; return undefined;
}); }
} }
function queryBuildingsByReference(key, id) { async function queryBuildingsByReference(key: string, ref: string) {
try {
if (key === 'toid') { if (key === 'toid') {
return db.manyOrNone( return await db.manyOrNone(
`SELECT `SELECT
* *
FROM FROM
@ -50,14 +67,10 @@ function queryBuildingsByReference(key, id) {
WHERE WHERE
ref_toid = $1 ref_toid = $1
`, `,
[id] [ref]
).catch(function (error) { );
console.error(error); } else if (key === 'uprn') {
return undefined; return await db.manyOrNone(
});
}
if (key === 'uprn') {
return db.manyOrNone(
`SELECT `SELECT
b.* b.*
FROM FROM
@ -67,90 +80,208 @@ function queryBuildingsByReference(key, id) {
AND AND
p.uprn = $1 p.uprn = $1
`, `,
[id] [ref]
).catch(function (error) { );
console.error(error); } else {
return undefined; 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) { async function getBuildingById(id: number) {
return db.one( try {
const building = await db.one(
'SELECT * FROM buildings WHERE building_id = $1', 'SELECT * FROM buildings WHERE building_id = $1',
[id] [id]
).then((building) => { );
return getBuildingEditHistory(id).then((edit_history) => {
building.edit_history = edit_history building.edit_history = await getBuildingEditHistory(id);
return building
}) return building;
}).catch(function (error) { } catch(error) {
console.error(error); console.error(error);
return undefined; return undefined;
}); }
} }
function getBuildingEditHistory(id) { async function getBuildingEditHistory(id: number) {
return db.manyOrNone( try {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username `SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
FROM logs, users FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id`, WHERE building_id = $1 AND logs.user_id = users.user_id`,
[id] [id]
).then((data) => { );
return data } catch(error) {
}).catch(function (error) {
console.error(error); console.error(error);
return [] return [];
}); }
} }
function getBuildingLikeById(buildingId, userId) { async function getBuildingLikeById(buildingId: number, userId: string) {
return db.oneOrNone( try {
const res = await db.oneOrNone(
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1', 'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
[buildingId, userId] [buildingId, userId]
).then(res => { );
return res && res.like return res && res.like;
}).catch(function (error) { } catch(error) {
console.error(error); console.error(error);
return undefined; return undefined;
}); }
} }
function getBuildingUPRNsById(id) { async function getBuildingUPRNsById(id: number) {
return db.any( try {
return await db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1', 'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id] [id]
).catch(function (error) { );
} catch(error) {
console.error(error); console.error(error);
return undefined; 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 // remove read-only fields from consideration
delete building.building_id; delete building.building_id;
delete building.revision_id; delete building.revision_id;
delete building.geometry_id; delete building.geometry_id;
// start transaction around save operation // return whitelisted fields to update
// - select and compare to identify changeset return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
// - insert changeset });
// - update to latest state } catch(error) {
// commit or rollback (repeated-read sufficient? or serializable?) console.error(error);
return db.tx(t => { 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( 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;', 'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId] [buildingId]
).then(oldBuilding => { );
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches) console.log('Patching', buildingId, patches)
const forward = patches[0]; const [forward, reverse] = patches;
const reverse = patches[1];
if (Object.keys(forward).length === 0) { 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 ( `INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id forward_patch, reverse_patch, building_id, user_id
) VALUES ( ) VALUES (
@ -158,10 +289,12 @@ function saveBuilding(buildingId, building, userId) {
) RETURNING log_id ) RETURNING log_id
`, `,
[forward, reverse, buildingId, userId] [forward, reverse, buildingId, userId]
).then(revision => { );
const sets = db.$config.pgp.helpers.sets(forward); const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets) console.log('Setting', buildingId, sets);
return t.one(
const data = await t.one(
`UPDATE `UPDATE
buildings buildings
SET SET
@ -173,127 +306,15 @@ function saveBuilding(buildingId, building, userId) {
* *
`, `,
[revision.log_id, sets, buildingId] [revision.log_id, sets, buildingId]
).then((data) => { );
expireBuildingTileCache(buildingId)
return data expireBuildingTileCache(buildingId);
})
}); return data;
});
}).catch(function (error) {
console.error(error);
return { error: error };
}); });
} }
function likeBuilding(buildingId, userId) { function privateQueryBuildingBBOX(buildingId: number){
// 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){
return db.one( return db.one(
`SELECT `SELECT
ST_XMin(envelope) as xmin, ST_XMin(envelope) as xmin,
@ -310,14 +331,13 @@ function privateQueryBuildingBBOX(buildingId){
b.building_id = $1 b.building_id = $1
) as envelope`, ) as envelope`,
[buildingId] [buildingId]
) );
} }
function expireBuildingTileCache(buildingId) { async function expireBuildingTileCache(buildingId: number) {
privateQueryBuildingBBOX(buildingId).then((bbox) => { const bbox = await privateQueryBuildingBBOX(buildingId)
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin]; const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox); tileCache.removeAllAtBbox(buildingBbox);
})
} }
const BUILDING_FIELD_WHITELIST = new Set([ const BUILDING_FIELD_WHITELIST = new Set([
@ -385,16 +405,16 @@ const BUILDING_FIELD_WHITELIST = new Set([
* @param {Set} whitelist * @param {Set} whitelist
* @returns {[object, object]} * @returns {[object, object]}
*/ */
function compare(oldObj, newObj, whitelist) { function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {} const reverse = {};
const forward = {} const forward = {};
for (const [key, value] of Object.entries(newObj)) { for (const [key, value] of Object.entries(newObj)) {
if (oldObj[key] !== value && whitelist.has(key)) { if (oldObj[key] != value) {
reverse[key] = oldObj[key]; reverse[key] = oldObj[key];
forward[key] = value; forward[key] = value;
} }
} }
return [forward, reverse] return [forward, reverse];
} }
export { export {
@ -405,5 +425,6 @@ export {
getBuildingUPRNsById, getBuildingUPRNsById,
saveBuilding, saveBuilding,
likeBuilding, likeBuilding,
unlikeBuilding unlikeBuilding,
getLatestRevisionId
}; };

View File

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

View File

@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
hydrate( hydrate(
<BrowserRouter> <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>, </BrowserRouter>,
document.getElementById('root') document.getElementById('root')
); );

View File

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

View File

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

View File

@ -114,22 +114,6 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1} step={0.1}
disabled={true} 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 <SelectDataEntry
title="Roof shape" title="Roof shape"
slug="size_roof_shape" slug="size_roof_shape"

View File

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

View File

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

View File

@ -21,6 +21,7 @@ interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: Building; building: Building;
building_like: boolean; building_like: boolean;
user: any; user: any;
revisionId: number;
} }
interface MapAppState { interface MapAppState {
@ -41,12 +42,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
constructor(props: Readonly<MapAppProps>) { constructor(props: Readonly<MapAppProps>) {
super(props); super(props);
// set building revision id, default 0
const rev = props.building != undefined ? +props.building.revision_id : 0;
this.state = { this.state = {
category: this.getCategory(props.match.params.category), category: this.getCategory(props.match.params.category),
revision_id: rev, revision_id: props.revisionId || 0,
building: props.building, building: props.building,
building_like: props.building_like 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) { getCategory(category: string) {
if (category === 'categories') return undefined; if (category === 'categories') return undefined;

View File

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

View File

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

View File

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