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
} }
res.send(building) if (building.error) {
}).catch( return res.send(building);
() => res.send({ error: 'Database error' }) }
) res.send(building);
} catch(err) {
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 {
if (building.error) { const building = like ?
res.send(building) await buildingService.likeBuilding(building_id, req.session.user_id) :
return await buildingService.unlikeBuilding(building_id, req.session.user_id);
}
if (typeof (building) === 'undefined') { if (building.error) {
res.send({ error: 'Database error' }) return res.send(building);
return }
} if (typeof (building) === 'undefined') {
res.send(building) return res.send({ error: 'Database error' });
}).catch( }
() => res.send({ error: 'Database error' }) res.send(building);
) } catch(error) {
} else { res.send({ error: 'Database error' });
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' })
)
} }
} });
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 { 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,282 +19,302 @@ const serializable = new TransactionMode({
readOnly: false readOnly: false
}); });
function queryBuildingsAtPoint(lng, lat) { async function getLatestRevisionId() {
return db.manyOrNone( try {
`SELECT b.* const data = await db.oneOrNone(
FROM buildings as b, geometries as g `SELECT MAX(log_id) from logs`
WHERE );
b.geometry_id = g.geometry_id return data == undefined ? undefined : data.max;
AND } catch(err) {
ST_Intersects( console.error(err);
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`,
[lng, lat]
).catch(function (error) {
console.error(error);
return undefined; return undefined;
}); }
} }
function queryBuildingsByReference(key, id) {
if (key === 'toid') { async function queryBuildingsAtPoint(lng: number, lat: number) {
return db.manyOrNone( try {
`SELECT return await db.manyOrNone(
* `SELECT b.*
FROM FROM buildings as b, geometries as g
buildings
WHERE WHERE
ref_toid = $1 b.geometry_id = g.geometry_id
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
if (key === 'uprn') {
return db.manyOrNone(
`SELECT
b.*
FROM
buildings as b, building_properties as p
WHERE
b.building_id = p.building_id
AND AND
p.uprn = $1 ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`, `,
[id] [lng, lat]
).catch(function (error) { );
console.error(error); } catch(error) {
return undefined; console.error(error);
}); return undefined;
} }
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
} }
function getBuildingById(id) { async function queryBuildingsByReference(key: string, ref: string) {
return db.one( try {
'SELECT * FROM buildings WHERE building_id = $1', if (key === 'toid') {
[id] return await db.manyOrNone(
).then((building) => { `SELECT
return getBuildingEditHistory(id).then((edit_history) => { *
building.edit_history = edit_history FROM
return building buildings
}) WHERE
}).catch(function (error) { ref_toid = $1
console.error(error);
return undefined;
});
}
function getBuildingEditHistory(id) {
return 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) {
console.error(error);
return []
});
}
function getBuildingLikeById(buildingId, userId) {
return 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) {
console.error(error);
return undefined;
});
}
function getBuildingUPRNsById(id) {
return db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
function saveBuilding(buildingId, building, userId) {
// 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 t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
).then(oldBuilding => {
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
console.log('Patching', buildingId, patches)
const forward = patches[0];
const reverse = patches[1];
if (Object.keys(forward).length === 0) {
return Promise.reject('No change provided')
}
return t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`, `,
[forward, reverse, buildingId, userId] [ref]
).then(revision => { );
const sets = db.$config.pgp.helpers.sets(forward); } else if (key === 'uprn') {
console.log('Setting', buildingId, sets) return await db.manyOrNone(
return t.one( `SELECT
`UPDATE b.*
buildings FROM
SET buildings as b, building_properties as p
revision_id = $1, WHERE
$2:raw b.building_id = p.building_id
WHERE AND
building_id = $3 p.uprn = $1
RETURNING `,
* [ref]
`, );
[revision.log_id, sets, buildingId] } else {
).then((data) => { return { error: 'Key must be UPRN or TOID' };
expireBuildingTileCache(buildingId) }
return data } catch(err) {
}) console.error(err);
}); return undefined;
}
}
async function getBuildingById(id: number) {
try {
const building = await db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
);
building.edit_history = await getBuildingEditHistory(id);
return building;
} catch(error) {
console.error(error);
return undefined;
}
}
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]
);
} catch(error) {
console.error(error);
return [];
}
}
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;
}
}
async function getBuildingUPRNsById(id: number) {
try {
return await db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id]
);
} catch(error) {
console.error(error);
return undefined;
}
}
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;
// return whitelisted fields to update
return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
}); });
}).catch(function (error) { } catch(error) {
console.error(error); console.error(error);
return { error: error }; return { error: error };
}); }
} }
function likeBuilding(buildingId, userId) { async function likeBuilding(buildingId: number, userId: string) {
// start transaction around save operation try {
// - insert building-user like return await updateBuildingData(
// - count total likes buildingId,
// - insert changeset userId,
// - update building to latest state async (t) => {
// commit or rollback (serializable - could be more compact?) // return total like count after update
return db.tx({mode: serializable}, t => { return getBuildingLikeCount(buildingId, t);
return t.none( },
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);', async (t) => {
[buildingId, userId] // insert building-user like
).then(() => { await t.none(
return t.one( 'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;', [buildingId, userId]
[buildingId] );
).then(building => { },
return t.one( );
`INSERT INTO logs ( } catch (error) {
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); console.error(error);
if (error.detail && error.detail.includes('already exists')) { if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it // 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' }; return { error: 'It looks like you already like that building!' };
} else { } else {
return undefined return undefined;
} }
}); }
} }
function unlikeBuilding(buildingId, userId) { async function unlikeBuilding(buildingId: number, userId: string) {
// start transaction around save operation try {
// - insert building-user like return await updateBuildingData(
// - count total likes buildingId,
// - insert changeset userId,
// - update building to latest state async (t) => {
// commit or rollback (serializable - could be more compact?) // return total like count after update
return db.tx({mode: serializable}, t => { return getBuildingLikeCount(buildingId, t);
return t.none( },
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;', async (t) => {
[buildingId, userId] // remove building-user like
).then(() => { const result = await t.result(
return t.one( 'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;', [buildingId, userId]
[buildingId] );
).then(building => {
return t.one( if (result.rowCount === 0) {
`INSERT INTO logs ( throw new Error('No change');
forward_patch, building_id, user_id }
},
);
} 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]
);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches)
const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) {
throw 'No change provided';
}
const revision = await t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES ( ) VALUES (
$1:json, $2, $3 $1:json, $2:json, $3, $4
) RETURNING log_id ) RETURNING log_id
`, `,
[{ likes_total: building.likes }, buildingId, userId] [forward, reverse, buildingId, userId]
).then(revision => { );
return t.one(
`UPDATE buildings const sets = db.$config.pgp.helpers.sets(forward);
SET console.log('Setting', buildingId, sets);
revision_id = $1,
likes_total = $2 const data = await t.one(
WHERE `UPDATE
building_id = $3 buildings
RETURNING SET
* revision_id = $1,
`, $2:raw
[revision.log_id, building.likes, buildingId] WHERE
).then((data) => { building_id = $3
expireBuildingTileCache(buildingId) RETURNING
return data *
}) `,
}) [revision.log_id, sets, buildingId]
}); );
});
}).catch(function (error) { expireBuildingTileCache(buildingId);
console.error(error);
if (error.detail && error.detail.includes('already exists')) { return data;
// '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( 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,173 +6,185 @@ 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 {
`INSERT return await db.one(
INTO users ( `INSERT
user_id, INTO users (
username, user_id,
email, username,
pass email,
) VALUES ( pass
gen_random_uuid(), ) VALUES (
$1, gen_random_uuid(),
$2, $1,
crypt($3, gen_salt('bf')) $2,
) RETURNING user_id crypt($3, gen_salt('bf'))
`, [ ) RETURNING user_id
user.username, `, [
user.email, user.username,
user.password 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.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 {
`SELECT const user = await db.one(
user_id, `SELECT
( user_id,
pass = crypt($2, pass) (
) AS auth_ok pass = crypt($2, pass)
FROM users ) AS auth_ok
WHERE FROM users
username = $1 WHERE
`, [ username = $1
username, `, [
password username,
] 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 {
`SELECT return await db.one(
username, email, registered, api_key `SELECT
FROM username, email, registered, api_key
users FROM
WHERE users
user_id = $1 WHERE
`, [ user_id = $1
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) {
return db.one( try {
`SELECT return db.one(
user_id, username, email `SELECT
FROM user_id, username, email
users FROM
WHERE users
email = $1 WHERE
`, [email] email = $1
).catch(function(error) { `, [email]
);
} catch(error) {
console.error('Error:', error); console.error('Error:', error);
return undefined; return undefined;
}); }
} }
function getNewUserAPIKey(id) { async function getNewUserAPIKey(id: string) {
return db.one( try{
`UPDATE return db.one(
users `UPDATE
SET users
api_key = gen_random_uuid() SET
WHERE api_key = gen_random_uuid()
user_id = $1 WHERE
RETURNING user_id = $1
api_key RETURNING
`, [ api_key
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 {
`SELECT return await db.one(
user_id `SELECT
FROM user_id
users FROM
WHERE users
api_key = $1 WHERE
`, [ api_key = $1
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 {
`UPDATE users return await db.none(
SET `UPDATE users
email = null, SET
pass = null, email = null,
api_key = null, pass = null,
username = concat('deleted_', cast(user_id as char(13))), api_key = null,
is_deleted = true, username = concat('deleted_', cast(user_id as char(13))),
deleted_on = now() at time zone 'utc' is_deleted = true,
WHERE user_id = $1 deleted_on = now() at time zone 'utc'
`, [id] WHERE user_id = $1
).catch((error) => { `, [id]
);
} 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 => { return promisify(session.destroy.bind(session))();
if (err) return reject(err);
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>
); );