Merge branch 'develop' into feature/data-container-state
This commit is contained in:
commit
cf18e5bb70
7
app/public/geometries/boundary-detailed.geojson
Normal file
7
app/public/geometries/boundary-detailed.geojson
Normal file
File diff suppressed because one or more lines are too long
8
app/public/geometries/boundary.geojson
Normal file
8
app/public/geometries/boundary.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
};
|
@ -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)) {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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')
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
<App revisionId={0} />
|
||||
</MemoryRouter>,
|
||||
div
|
||||
);
|
||||
|
@ -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} />
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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" />
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user