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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
import { ITask } from 'pg-promise';
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
// JavaScript numerics are 64-bit double, giving only partial coverage.
@ -18,282 +19,302 @@ const serializable = new TransactionMode({
readOnly: false
});
function queryBuildingsAtPoint(lng, lat) {
return db.manyOrNone(
`SELECT b.*
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`,
[lng, lat]
).catch(function (error) {
console.error(error);
async function getLatestRevisionId() {
try {
const data = await db.oneOrNone(
`SELECT MAX(log_id) from logs`
);
return data == undefined ? undefined : data.max;
} catch(err) {
console.error(err);
return undefined;
});
}
}
function queryBuildingsByReference(key, id) {
if (key === 'toid') {
return db.manyOrNone(
`SELECT
*
FROM
buildings
async function queryBuildingsAtPoint(lng: number, lat: number) {
try {
return await db.manyOrNone(
`SELECT b.*
FROM buildings as b, geometries as g
WHERE
ref_toid = $1
`,
[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
b.geometry_id = g.geometry_id
AND
p.uprn = $1
ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
[lng, lat]
);
} catch(error) {
console.error(error);
return undefined;
}
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
}
function getBuildingById(id) {
return db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
).then((building) => {
return getBuildingEditHistory(id).then((edit_history) => {
building.edit_history = edit_history
return building
})
}).catch(function (error) {
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
async function queryBuildingsByReference(key: string, ref: string) {
try {
if (key === 'toid') {
return await db.manyOrNone(
`SELECT
*
FROM
buildings
WHERE
ref_toid = $1
`,
[forward, reverse, buildingId, userId]
).then(revision => {
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets)
return t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
});
[ref]
);
} else if (key === 'uprn') {
return await db.manyOrNone(
`SELECT
b.*
FROM
buildings as b, building_properties as p
WHERE
b.building_id = p.building_id
AND
p.uprn = $1
`,
[ref]
);
} else {
return { error: 'Key must be UPRN or TOID' };
}
} 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);
return { error: error };
});
}
}
function likeBuilding(buildingId, userId) {
// start transaction around save operation
// - insert building-user like
// - count total likes
// - insert changeset
// - update building to latest state
// commit or rollback (serializable - could be more compact?)
return db.tx({mode: serializable}, t => {
return t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
).then(() => {
return t.one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
).then(building => {
return t.one(
`INSERT INTO logs (
forward_patch, building_id, user_id
) VALUES (
$1:json, $2, $3
) RETURNING log_id
`,
[{ likes_total: building.likes }, buildingId, userId]
).then(revision => {
return t.one(
`UPDATE buildings
SET
revision_id = $1,
likes_total = $2
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, building.likes, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
})
});
});
}).catch(function (error) {
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
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
async function unlikeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
},
async (t) => {
// remove building-user like
const result = await t.result(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
);
if (result.rowCount === 0) {
throw new Error('No change');
}
},
);
} catch(error) {
console.error(error);
if (error.message === 'No change') {
// 'No change' is thrown if user doesn't like this building
return { error: 'It looks like you have already revoked your like for that building!' };
} else {
return undefined;
}
}
}
// === Utility functions ===
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
const subObject = {};
for (let [key, value] of Object.entries(obj)) {
if(fieldWhitelist.has(key)) {
subObject[key] = value;
}
}
return subObject;
}
/**
*
* @param buildingId ID of the building to count likes for
* @param t The database context inside which the count should happen
*/
function getBuildingLikeCount(buildingId: number, t: ITask<unknown>) {
return t.one(
'SELECT count(*) as likes_total FROM building_user_likes WHERE building_id = $1;',
[buildingId]
);
}
/**
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
* All db hooks get passed a transaction.
* @param buildingId The ID of the building to update
* @param userId The ID of the user updating the data
* @param getUpdateValue Function returning the set of attribute to update for the building
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
*/
async function updateBuildingData(
buildingId: number,
userId: string,
getUpdateValue: (t: ITask<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
) {
return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t);
}
const update = await getUpdateValue(t);
const oldBuilding = await t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
);
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 (
$1:json, $2, $3
$1:json, $2:json, $3, $4
) 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
}
[forward, reverse, buildingId, userId]
);
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets);
const data = await t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
);
expireBuildingTileCache(buildingId);
return data;
});
}
function privateQueryBuildingBBOX(buildingId){
function privateQueryBuildingBBOX(buildingId: number){
return db.one(
`SELECT
ST_XMin(envelope) as xmin,
@ -310,14 +331,13 @@ function privateQueryBuildingBBOX(buildingId){
b.building_id = $1
) as envelope`,
[buildingId]
)
);
}
function expireBuildingTileCache(buildingId) {
privateQueryBuildingBBOX(buildingId).then((bbox) => {
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox);
})
async function expireBuildingTileCache(buildingId: number) {
const bbox = await privateQueryBuildingBBOX(buildingId)
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox);
}
const BUILDING_FIELD_WHITELIST = new Set([
@ -385,16 +405,16 @@ const BUILDING_FIELD_WHITELIST = new Set([
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj, newObj, whitelist) {
const reverse = {}
const forward = {}
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (oldObj[key] !== value && whitelist.has(key)) {
if (oldObj[key] != value) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse]
return [forward, reverse];
}
export {
@ -405,5 +425,6 @@ export {
getBuildingUPRNsById,
saveBuilding,
likeBuilding,
unlikeBuilding
unlikeBuilding,
getLatestRevisionId
};

View File

@ -6,173 +6,185 @@ import { errors } from 'pg-promise';
import db from '../../db';
import { validateUsername, ValidationError, validatePassword } from '../validation';
import { promisify } from 'util';
function createUser(user) {
async function createUser(user) {
try {
validateUsername(user.username);
validatePassword(user.password);
} catch(err) {
if (err instanceof ValidationError) {
return Promise.reject({ error: err.message });
throw { error: err.message };
} else throw err;
}
return db.one(
`INSERT
INTO users (
user_id,
username,
email,
pass
) VALUES (
gen_random_uuid(),
$1,
$2,
crypt($3, gen_salt('bf'))
) RETURNING user_id
`, [
user.username,
user.email,
user.password
]
).catch(function (error) {
console.error('Error:', error)
try {
return await db.one(
`INSERT
INTO users (
user_id,
username,
email,
pass
) VALUES (
gen_random_uuid(),
$1,
$2,
crypt($3, gen_salt('bf'))
) RETURNING user_id
`, [
user.username,
user.email,
user.password
]
);
} catch(error) {
console.error('Error:', error);
if (error.detail.indexOf('already exists') !== -1) {
if (error.detail.indexOf('username') !== -1) {
if (error.detail.includes('already exists')) {
if (error.detail.includes('username')) {
return { error: 'Username already registered' };
} else if (error.detail.indexOf('email') !== -1) {
} else if (error.detail.includes('email')) {
return { error: 'Email already registered' };
}
}
return { error: 'Database error' }
});
return { error: 'Database error' };
}
}
function authUser(username, password) {
return db.one(
`SELECT
user_id,
(
pass = crypt($2, pass)
) AS auth_ok
FROM users
WHERE
username = $1
`, [
username,
password
]
).then(function (user) {
async function authUser(username: string, password: string) {
try {
const user = await db.one(
`SELECT
user_id,
(
pass = crypt($2, pass)
) AS auth_ok
FROM users
WHERE
username = $1
`, [
username,
password
]
);
if (user && user.auth_ok) {
return { user_id: user.user_id }
} else {
return { error: 'Username or password not recognised' }
}
}).catch(function (err) {
} catch(err) {
if (err instanceof errors.QueryResultError) {
console.error(`Authentication failed for user ${username}`);
return { error: 'Username or password not recognised' };
}
console.error('Error:', err);
return { error: 'Database error' };
})
}
}
function getUserById(id) {
return db.one(
`SELECT
username, email, registered, api_key
FROM
users
WHERE
user_id = $1
`, [
id
]
).catch(function (error) {
async function getUserById(id: string) {
try {
return await db.one(
`SELECT
username, email, registered, api_key
FROM
users
WHERE
user_id = $1
`, [
id
]
);
} catch(error) {
console.error('Error:', error)
return undefined;
});
}
}
function getUserByEmail(email: string) {
return db.one(
`SELECT
user_id, username, email
FROM
users
WHERE
email = $1
`, [email]
).catch(function(error) {
async function getUserByEmail(email: string) {
try {
return db.one(
`SELECT
user_id, username, email
FROM
users
WHERE
email = $1
`, [email]
);
} catch(error) {
console.error('Error:', error);
return undefined;
});
}
}
function getNewUserAPIKey(id) {
return db.one(
`UPDATE
users
SET
api_key = gen_random_uuid()
WHERE
user_id = $1
RETURNING
api_key
`, [
id
]
).catch(function (error) {
async function getNewUserAPIKey(id: string) {
try{
return db.one(
`UPDATE
users
SET
api_key = gen_random_uuid()
WHERE
user_id = $1
RETURNING
api_key
`, [
id
]
);
} catch(error) {
console.error('Error:', error)
return { error: 'Failed to generate new API key.' };
});
}
}
function authAPIUser(key) {
return db.one(
`SELECT
user_id
FROM
users
WHERE
api_key = $1
`, [
key
]
).catch(function (error) {
async function authAPIUser(key: string) {
try {
return await db.one(
`SELECT
user_id
FROM
users
WHERE
api_key = $1
`, [
key
]
);
} catch(error) {
console.error('Error:', error)
return undefined;
});
}
}
function deleteUser(id) {
return db.none(
`UPDATE users
SET
email = null,
pass = null,
api_key = null,
username = concat('deleted_', cast(user_id as char(13))),
is_deleted = true,
deleted_on = now() at time zone 'utc'
WHERE user_id = $1
`, [id]
).catch((error) => {
async function deleteUser(id: string) {
try {
return await db.none(
`UPDATE users
SET
email = null,
pass = null,
api_key = null,
username = concat('deleted_', cast(user_id as char(13))),
is_deleted = true,
deleted_on = now() at time zone 'utc'
WHERE user_id = $1
`, [id]
);
} catch(error) {
console.error('Error:', error);
return {error: 'Database error'};
});
}
}
function logout(session: Express.Session) {
return new Promise((resolve, reject) => {
session.user_id = undefined;
session.destroy(err => {
if (err) return reject(err);
return resolve();
});
});
function logout(session: Express.Session): Promise<void> {
session.user_id = undefined;
return promisify(session.destroy.bind(session))();
}
export {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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