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 buildingService from '../services/building';
|
||||||
import * as userService from '../services/user';
|
import * as userService from '../services/user';
|
||||||
|
import asyncController from '../routes/asyncController';
|
||||||
|
|
||||||
|
|
||||||
// GET buildings
|
// GET buildings
|
||||||
// not implemented - may be useful to GET all buildings, paginated
|
// not implemented - may be useful to GET all buildings, paginated
|
||||||
|
|
||||||
// GET buildings at point
|
// GET buildings at point
|
||||||
function getBuildingsByLocation(req, res) {
|
const getBuildingsByLocation = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const { lng, lat } = req.query;
|
const { lng, lat } = req.query;
|
||||||
buildingService.queryBuildingsAtPoint(lng, lat).then(function (result) {
|
try {
|
||||||
|
const result = await buildingService.queryBuildingsAtPoint(lng, lat);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
}).catch(function (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.send({ error: 'Database error' })
|
res.send({ error: 'Database error' });
|
||||||
})
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// GET buildings by reference (UPRN/TOID or other identifier)
|
// GET buildings by reference (UPRN/TOID or other identifier)
|
||||||
function getBuildingsByReference(req, res) {
|
const getBuildingsByReference = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const { key, id } = req.query;
|
const { key, id } = req.query;
|
||||||
buildingService.queryBuildingsByReference(key, id).then(function (result) {
|
try {
|
||||||
|
const result = await buildingService.queryBuildingsByReference(key, id);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
}).catch(function (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.send({ error: 'Database error' })
|
res.send({ error: 'Database error' });
|
||||||
})
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// GET individual building, POST building updates
|
// GET individual building, POST building updates
|
||||||
function getBuildingById(req, res) {
|
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const { building_id } = req.params;
|
const { building_id } = req.params;
|
||||||
buildingService.getBuildingById(building_id).then(function (result) {
|
try {
|
||||||
|
const result = await buildingService.getBuildingById(building_id);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
}).catch(function (error) {
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.send({ error: 'Database error' })
|
res.send({ error: 'Database error' });
|
||||||
})
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function updateBuildingById(req, res) {
|
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
if (req.session.user_id) {
|
if (req.session.user_id) {
|
||||||
updateBuilding(req, res, req.session.user_id);
|
await updateBuilding(req, res, req.session.user_id);
|
||||||
} else if (req.query.api_key) {
|
} else if (req.query.api_key) {
|
||||||
userService.authAPIUser(req.query.api_key)
|
try {
|
||||||
.then(function (user) {
|
const user = await userService.authAPIUser(req.query.api_key);
|
||||||
updateBuilding(req, res, user.user_id)
|
await updateBuilding(req, res, user.user_id);
|
||||||
})
|
} catch(err) {
|
||||||
.catch(function (err) {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.send({ error: 'Must be logged in' });
|
res.send({ error: 'Must be logged in' });
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
res.send({ error: 'Must be logged in' });
|
res.send({ error: 'Must be logged in' });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function updateBuilding(req, res, userId) {
|
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
|
||||||
const { building_id } = req.params;
|
const { building_id } = req.params;
|
||||||
const building = req.body;
|
const buildingUpdate = req.body;
|
||||||
buildingService.saveBuilding(building_id, building, userId).then(building => {
|
|
||||||
if (building.error) {
|
try {
|
||||||
res.send(building)
|
const building = await buildingService.saveBuilding(building_id, buildingUpdate, userId);
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof (building) === 'undefined') {
|
if (typeof (building) === 'undefined') {
|
||||||
res.send({ error: 'Database error' })
|
return res.send({ error: 'Database error' });
|
||||||
return
|
}
|
||||||
|
if (building.error) {
|
||||||
|
return res.send(building);
|
||||||
|
}
|
||||||
|
res.send(building);
|
||||||
|
} catch(err) {
|
||||||
|
res.send({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
res.send(building)
|
|
||||||
}).catch(
|
|
||||||
() => res.send({ error: 'Database error' })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET building UPRNs
|
// GET building UPRNs
|
||||||
function getBuildingUPRNsById(req, res) {
|
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const { building_id } = req.params;
|
const { building_id } = req.params;
|
||||||
buildingService.getBuildingUPRNsById(building_id).then(function (result) {
|
try {
|
||||||
|
const result = await buildingService.getBuildingUPRNsById(building_id);
|
||||||
|
|
||||||
if (typeof (result) === 'undefined') {
|
if (typeof (result) === 'undefined') {
|
||||||
res.send({ error: 'Database error' })
|
return res.send({ error: 'Database error' });
|
||||||
return
|
|
||||||
}
|
}
|
||||||
res.send({
|
res.send({uprns: result});
|
||||||
uprns: result
|
} catch(error) {
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.send({ error: 'Database error' })
|
res.send({ error: 'Database error' });
|
||||||
})
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// GET/POST like building
|
// GET/POST like building
|
||||||
function getBuildingLikeById(req, res) {
|
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
if (!req.session.user_id) {
|
if (!req.session.user_id) {
|
||||||
res.send({ like: false }); // not logged in, so cannot have liked
|
return res.send({ like: false }); // not logged in, so cannot have liked
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const { building_id } = req.params;
|
const { building_id } = req.params;
|
||||||
buildingService.getBuildingLikeById(building_id, req.session.user_id).then(like => {
|
try {
|
||||||
// any value returned means like
|
const like = await buildingService.getBuildingLikeById(building_id, req.session.user_id);
|
||||||
res.send({ like: like })
|
|
||||||
}).catch(
|
|
||||||
() => res.send({ error: 'Database error' })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBuildingLikeById(req, res) {
|
// any value returned means like
|
||||||
if (!req.session.user_id) {
|
res.send({ like: like });
|
||||||
res.send({ error: 'Must be logged in' });
|
} catch(error) {
|
||||||
return
|
res.send({ error: 'Database error' })
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
|
if (!req.session.user_id) {
|
||||||
|
return res.send({ error: 'Must be logged in' });
|
||||||
|
}
|
||||||
|
|
||||||
const { building_id } = req.params;
|
const { building_id } = req.params;
|
||||||
const { like } = req.body;
|
const { like } = req.body;
|
||||||
if (like) {
|
|
||||||
buildingService.likeBuilding(building_id, req.session.user_id).then(building => {
|
try {
|
||||||
|
const building = like ?
|
||||||
|
await buildingService.likeBuilding(building_id, req.session.user_id) :
|
||||||
|
await buildingService.unlikeBuilding(building_id, req.session.user_id);
|
||||||
|
|
||||||
if (building.error) {
|
if (building.error) {
|
||||||
res.send(building)
|
return res.send(building);
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (typeof (building) === 'undefined') {
|
if (typeof (building) === 'undefined') {
|
||||||
res.send({ error: 'Database error' })
|
return res.send({ error: 'Database error' });
|
||||||
return
|
|
||||||
}
|
}
|
||||||
res.send(building)
|
res.send(building);
|
||||||
}).catch(
|
} catch(error) {
|
||||||
() => res.send({ error: 'Database error' })
|
res.send({ error: 'Database error' });
|
||||||
)
|
|
||||||
} else {
|
|
||||||
buildingService.unlikeBuilding(building_id, req.session.user_id).then(building => {
|
|
||||||
if (building.error) {
|
|
||||||
res.send(building)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (typeof (building) === 'undefined') {
|
});
|
||||||
res.send({ error: 'Database error' })
|
|
||||||
return
|
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const revisionId = await buildingService.getLatestRevisionId();
|
||||||
|
res.send({latestRevisionId: revisionId});
|
||||||
|
} catch(error) {
|
||||||
|
res.send({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
res.send(building)
|
});
|
||||||
}).catch(
|
|
||||||
() => res.send({ error: 'Database error' })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getBuildingsByLocation,
|
getBuildingsByLocation,
|
||||||
@ -150,5 +151,6 @@ export default {
|
|||||||
updateBuildingById,
|
updateBuildingById,
|
||||||
getBuildingUPRNsById,
|
getBuildingUPRNsById,
|
||||||
getBuildingLikeById,
|
getBuildingLikeById,
|
||||||
updateBuildingLikeById
|
updateBuildingLikeById,
|
||||||
|
getLatestRevisionId
|
||||||
};
|
};
|
@ -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)) {
|
||||||
|
@ -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)
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import db from '../../db';
|
import db from '../../db';
|
||||||
import { tileCache } from '../../tiles/rendererDefinition';
|
import { tileCache } from '../../tiles/rendererDefinition';
|
||||||
import { BoundingBox } from '../../tiles/types';
|
import { BoundingBox } from '../../tiles/types';
|
||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
|
||||||
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
||||||
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
||||||
@ -18,8 +19,22 @@ const serializable = new TransactionMode({
|
|||||||
readOnly: false
|
readOnly: false
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryBuildingsAtPoint(lng, lat) {
|
async function getLatestRevisionId() {
|
||||||
return db.manyOrNone(
|
try {
|
||||||
|
const data = await db.oneOrNone(
|
||||||
|
`SELECT MAX(log_id) from logs`
|
||||||
|
);
|
||||||
|
return data == undefined ? undefined : data.max;
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function queryBuildingsAtPoint(lng: number, lat: number) {
|
||||||
|
try {
|
||||||
|
return await db.manyOrNone(
|
||||||
`SELECT b.*
|
`SELECT b.*
|
||||||
FROM buildings as b, geometries as g
|
FROM buildings as b, geometries as g
|
||||||
WHERE
|
WHERE
|
||||||
@ -34,15 +49,17 @@ function queryBuildingsAtPoint(lng, lat) {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[lng, lat]
|
[lng, lat]
|
||||||
).catch(function (error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryBuildingsByReference(key, id) {
|
async function queryBuildingsByReference(key: string, ref: string) {
|
||||||
|
try {
|
||||||
if (key === 'toid') {
|
if (key === 'toid') {
|
||||||
return db.manyOrNone(
|
return await db.manyOrNone(
|
||||||
`SELECT
|
`SELECT
|
||||||
*
|
*
|
||||||
FROM
|
FROM
|
||||||
@ -50,14 +67,10 @@ function queryBuildingsByReference(key, id) {
|
|||||||
WHERE
|
WHERE
|
||||||
ref_toid = $1
|
ref_toid = $1
|
||||||
`,
|
`,
|
||||||
[id]
|
[ref]
|
||||||
).catch(function (error) {
|
);
|
||||||
console.error(error);
|
} else if (key === 'uprn') {
|
||||||
return undefined;
|
return await db.manyOrNone(
|
||||||
});
|
|
||||||
}
|
|
||||||
if (key === 'uprn') {
|
|
||||||
return db.manyOrNone(
|
|
||||||
`SELECT
|
`SELECT
|
||||||
b.*
|
b.*
|
||||||
FROM
|
FROM
|
||||||
@ -67,90 +80,208 @@ function queryBuildingsByReference(key, id) {
|
|||||||
AND
|
AND
|
||||||
p.uprn = $1
|
p.uprn = $1
|
||||||
`,
|
`,
|
||||||
[id]
|
[ref]
|
||||||
).catch(function (error) {
|
);
|
||||||
console.error(error);
|
} else {
|
||||||
return undefined;
|
return { error: 'Key must be UPRN or TOID' };
|
||||||
});
|
}
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildingById(id) {
|
async function getBuildingById(id: number) {
|
||||||
return db.one(
|
try {
|
||||||
|
const building = await db.one(
|
||||||
'SELECT * FROM buildings WHERE building_id = $1',
|
'SELECT * FROM buildings WHERE building_id = $1',
|
||||||
[id]
|
[id]
|
||||||
).then((building) => {
|
);
|
||||||
return getBuildingEditHistory(id).then((edit_history) => {
|
|
||||||
building.edit_history = edit_history
|
building.edit_history = await getBuildingEditHistory(id);
|
||||||
return building
|
|
||||||
})
|
return building;
|
||||||
}).catch(function (error) {
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildingEditHistory(id) {
|
async function getBuildingEditHistory(id: number) {
|
||||||
return db.manyOrNone(
|
try {
|
||||||
|
return await db.manyOrNone(
|
||||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
|
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
|
||||||
FROM logs, users
|
FROM logs, users
|
||||||
WHERE building_id = $1 AND logs.user_id = users.user_id`,
|
WHERE building_id = $1 AND logs.user_id = users.user_id`,
|
||||||
[id]
|
[id]
|
||||||
).then((data) => {
|
);
|
||||||
return data
|
} catch(error) {
|
||||||
}).catch(function (error) {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return []
|
return [];
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildingLikeById(buildingId, userId) {
|
async function getBuildingLikeById(buildingId: number, userId: string) {
|
||||||
return db.oneOrNone(
|
try {
|
||||||
|
const res = await db.oneOrNone(
|
||||||
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
||||||
[buildingId, userId]
|
[buildingId, userId]
|
||||||
).then(res => {
|
);
|
||||||
return res && res.like
|
return res && res.like;
|
||||||
}).catch(function (error) {
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildingUPRNsById(id) {
|
async function getBuildingUPRNsById(id: number) {
|
||||||
return db.any(
|
try {
|
||||||
|
return await db.any(
|
||||||
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
||||||
[id]
|
[id]
|
||||||
).catch(function (error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveBuilding(buildingId, building, userId) {
|
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
|
||||||
|
try {
|
||||||
|
return await updateBuildingData(buildingId, userId, async () => {
|
||||||
// remove read-only fields from consideration
|
// remove read-only fields from consideration
|
||||||
delete building.building_id;
|
delete building.building_id;
|
||||||
delete building.revision_id;
|
delete building.revision_id;
|
||||||
delete building.geometry_id;
|
delete building.geometry_id;
|
||||||
|
|
||||||
// start transaction around save operation
|
// return whitelisted fields to update
|
||||||
// - select and compare to identify changeset
|
return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
|
||||||
// - insert changeset
|
});
|
||||||
// - update to latest state
|
} catch(error) {
|
||||||
// commit or rollback (repeated-read sufficient? or serializable?)
|
console.error(error);
|
||||||
return db.tx(t => {
|
return { error: error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function likeBuilding(buildingId: number, userId: string) {
|
||||||
|
try {
|
||||||
|
return await updateBuildingData(
|
||||||
|
buildingId,
|
||||||
|
userId,
|
||||||
|
async (t) => {
|
||||||
|
// return total like count after update
|
||||||
|
return getBuildingLikeCount(buildingId, t);
|
||||||
|
},
|
||||||
|
async (t) => {
|
||||||
|
// insert building-user like
|
||||||
|
await t.none(
|
||||||
|
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (error.detail && error.detail.includes('already exists')) {
|
||||||
|
// 'already exists' is thrown if user already liked it
|
||||||
|
return { error: 'It looks like you already like that building!' };
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlikeBuilding(buildingId: number, userId: string) {
|
||||||
|
try {
|
||||||
|
return await updateBuildingData(
|
||||||
|
buildingId,
|
||||||
|
userId,
|
||||||
|
async (t) => {
|
||||||
|
// return total like count after update
|
||||||
|
return getBuildingLikeCount(buildingId, t);
|
||||||
|
},
|
||||||
|
async (t) => {
|
||||||
|
// remove building-user like
|
||||||
|
const result = await t.result(
|
||||||
|
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error('No change');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
if (error.message === 'No change') {
|
||||||
|
// 'No change' is thrown if user doesn't like this building
|
||||||
|
return { error: 'It looks like you have already revoked your like for that building!' };
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Utility functions ===
|
||||||
|
|
||||||
|
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
|
||||||
|
const subObject = {};
|
||||||
|
for (let [key, value] of Object.entries(obj)) {
|
||||||
|
if(fieldWhitelist.has(key)) {
|
||||||
|
subObject[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param buildingId ID of the building to count likes for
|
||||||
|
* @param t The database context inside which the count should happen
|
||||||
|
*/
|
||||||
|
function getBuildingLikeCount(buildingId: number, t: ITask<unknown>) {
|
||||||
return t.one(
|
return t.one(
|
||||||
|
'SELECT count(*) as likes_total FROM building_user_likes WHERE building_id = $1;',
|
||||||
|
[buildingId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
|
||||||
|
* All db hooks get passed a transaction.
|
||||||
|
* @param buildingId The ID of the building to update
|
||||||
|
* @param userId The ID of the user updating the data
|
||||||
|
* @param getUpdateValue Function returning the set of attribute to update for the building
|
||||||
|
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
|
||||||
|
*/
|
||||||
|
async function updateBuildingData(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
getUpdateValue: (t: ITask<any>) => Promise<object>,
|
||||||
|
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
|
||||||
|
) {
|
||||||
|
return await db.tx({mode: serializable}, async t => {
|
||||||
|
if (preUpdateDbAction != undefined) {
|
||||||
|
await preUpdateDbAction(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = await getUpdateValue(t);
|
||||||
|
|
||||||
|
const oldBuilding = await t.one(
|
||||||
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
|
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
|
||||||
[buildingId]
|
[buildingId]
|
||||||
).then(oldBuilding => {
|
);
|
||||||
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
|
|
||||||
|
console.log(update);
|
||||||
|
const patches = compare(oldBuilding, update);
|
||||||
console.log('Patching', buildingId, patches)
|
console.log('Patching', buildingId, patches)
|
||||||
const forward = patches[0];
|
const [forward, reverse] = patches;
|
||||||
const reverse = patches[1];
|
|
||||||
if (Object.keys(forward).length === 0) {
|
if (Object.keys(forward).length === 0) {
|
||||||
return Promise.reject('No change provided')
|
throw 'No change provided';
|
||||||
}
|
}
|
||||||
return t.one(
|
|
||||||
|
const revision = await t.one(
|
||||||
`INSERT INTO logs (
|
`INSERT INTO logs (
|
||||||
forward_patch, reverse_patch, building_id, user_id
|
forward_patch, reverse_patch, building_id, user_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@ -158,10 +289,12 @@ function saveBuilding(buildingId, building, userId) {
|
|||||||
) RETURNING log_id
|
) RETURNING log_id
|
||||||
`,
|
`,
|
||||||
[forward, reverse, buildingId, userId]
|
[forward, reverse, buildingId, userId]
|
||||||
).then(revision => {
|
);
|
||||||
|
|
||||||
const sets = db.$config.pgp.helpers.sets(forward);
|
const sets = db.$config.pgp.helpers.sets(forward);
|
||||||
console.log('Setting', buildingId, sets)
|
console.log('Setting', buildingId, sets);
|
||||||
return t.one(
|
|
||||||
|
const data = await t.one(
|
||||||
`UPDATE
|
`UPDATE
|
||||||
buildings
|
buildings
|
||||||
SET
|
SET
|
||||||
@ -173,127 +306,15 @@ function saveBuilding(buildingId, building, userId) {
|
|||||||
*
|
*
|
||||||
`,
|
`,
|
||||||
[revision.log_id, sets, buildingId]
|
[revision.log_id, sets, buildingId]
|
||||||
).then((data) => {
|
);
|
||||||
expireBuildingTileCache(buildingId)
|
|
||||||
return data
|
expireBuildingTileCache(buildingId);
|
||||||
})
|
|
||||||
});
|
return data;
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.error(error);
|
|
||||||
return { error: error };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function likeBuilding(buildingId, userId) {
|
function privateQueryBuildingBBOX(buildingId: number){
|
||||||
// start transaction around save operation
|
|
||||||
// - insert building-user like
|
|
||||||
// - count total likes
|
|
||||||
// - insert changeset
|
|
||||||
// - update building to latest state
|
|
||||||
// commit or rollback (serializable - could be more compact?)
|
|
||||||
return db.tx({mode: serializable}, t => {
|
|
||||||
return t.none(
|
|
||||||
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
|
|
||||||
[buildingId, userId]
|
|
||||||
).then(() => {
|
|
||||||
return t.one(
|
|
||||||
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
|
|
||||||
[buildingId]
|
|
||||||
).then(building => {
|
|
||||||
return t.one(
|
|
||||||
`INSERT INTO logs (
|
|
||||||
forward_patch, building_id, user_id
|
|
||||||
) VALUES (
|
|
||||||
$1:json, $2, $3
|
|
||||||
) RETURNING log_id
|
|
||||||
`,
|
|
||||||
[{ likes_total: building.likes }, buildingId, userId]
|
|
||||||
).then(revision => {
|
|
||||||
return t.one(
|
|
||||||
`UPDATE buildings
|
|
||||||
SET
|
|
||||||
revision_id = $1,
|
|
||||||
likes_total = $2
|
|
||||||
WHERE
|
|
||||||
building_id = $3
|
|
||||||
RETURNING
|
|
||||||
*
|
|
||||||
`,
|
|
||||||
[revision.log_id, building.likes, buildingId]
|
|
||||||
).then((data) => {
|
|
||||||
expireBuildingTileCache(buildingId)
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.error(error);
|
|
||||||
if (error.detail && error.detail.includes('already exists')) {
|
|
||||||
// 'already exists' is thrown if user already liked it
|
|
||||||
return { error: 'It looks like you already like that building!' };
|
|
||||||
} else {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlikeBuilding(buildingId, userId) {
|
|
||||||
// start transaction around save operation
|
|
||||||
// - insert building-user like
|
|
||||||
// - count total likes
|
|
||||||
// - insert changeset
|
|
||||||
// - update building to latest state
|
|
||||||
// commit or rollback (serializable - could be more compact?)
|
|
||||||
return db.tx({mode: serializable}, t => {
|
|
||||||
return t.none(
|
|
||||||
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
|
|
||||||
[buildingId, userId]
|
|
||||||
).then(() => {
|
|
||||||
return t.one(
|
|
||||||
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
|
|
||||||
[buildingId]
|
|
||||||
).then(building => {
|
|
||||||
return t.one(
|
|
||||||
`INSERT INTO logs (
|
|
||||||
forward_patch, building_id, user_id
|
|
||||||
) VALUES (
|
|
||||||
$1:json, $2, $3
|
|
||||||
) RETURNING log_id
|
|
||||||
`,
|
|
||||||
[{ likes_total: building.likes }, buildingId, userId]
|
|
||||||
).then(revision => {
|
|
||||||
return t.one(
|
|
||||||
`UPDATE buildings
|
|
||||||
SET
|
|
||||||
revision_id = $1,
|
|
||||||
likes_total = $2
|
|
||||||
WHERE
|
|
||||||
building_id = $3
|
|
||||||
RETURNING
|
|
||||||
*
|
|
||||||
`,
|
|
||||||
[revision.log_id, building.likes, buildingId]
|
|
||||||
).then((data) => {
|
|
||||||
expireBuildingTileCache(buildingId)
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.error(error);
|
|
||||||
if (error.detail && error.detail.includes('already exists')) {
|
|
||||||
// 'already exists' is thrown if user already liked it
|
|
||||||
return { error: 'It looks like you already like that building!' };
|
|
||||||
} else {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function privateQueryBuildingBBOX(buildingId){
|
|
||||||
return db.one(
|
return db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
ST_XMin(envelope) as xmin,
|
ST_XMin(envelope) as xmin,
|
||||||
@ -310,14 +331,13 @@ function privateQueryBuildingBBOX(buildingId){
|
|||||||
b.building_id = $1
|
b.building_id = $1
|
||||||
) as envelope`,
|
) as envelope`,
|
||||||
[buildingId]
|
[buildingId]
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expireBuildingTileCache(buildingId) {
|
async function expireBuildingTileCache(buildingId: number) {
|
||||||
privateQueryBuildingBBOX(buildingId).then((bbox) => {
|
const bbox = await privateQueryBuildingBBOX(buildingId)
|
||||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||||
tileCache.removeAllAtBbox(buildingBbox);
|
tileCache.removeAllAtBbox(buildingBbox);
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILDING_FIELD_WHITELIST = new Set([
|
const BUILDING_FIELD_WHITELIST = new Set([
|
||||||
@ -385,16 +405,16 @@ const BUILDING_FIELD_WHITELIST = new Set([
|
|||||||
* @param {Set} whitelist
|
* @param {Set} whitelist
|
||||||
* @returns {[object, object]}
|
* @returns {[object, object]}
|
||||||
*/
|
*/
|
||||||
function compare(oldObj, newObj, whitelist) {
|
function compare(oldObj: object, newObj: object): [object, object] {
|
||||||
const reverse = {}
|
const reverse = {};
|
||||||
const forward = {}
|
const forward = {};
|
||||||
for (const [key, value] of Object.entries(newObj)) {
|
for (const [key, value] of Object.entries(newObj)) {
|
||||||
if (oldObj[key] !== value && whitelist.has(key)) {
|
if (oldObj[key] != value) {
|
||||||
reverse[key] = oldObj[key];
|
reverse[key] = oldObj[key];
|
||||||
forward[key] = value;
|
forward[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [forward, reverse]
|
return [forward, reverse];
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -405,5 +425,6 @@ export {
|
|||||||
getBuildingUPRNsById,
|
getBuildingUPRNsById,
|
||||||
saveBuilding,
|
saveBuilding,
|
||||||
likeBuilding,
|
likeBuilding,
|
||||||
unlikeBuilding
|
unlikeBuilding,
|
||||||
|
getLatestRevisionId
|
||||||
};
|
};
|
||||||
|
@ -6,18 +6,21 @@ import { errors } from 'pg-promise';
|
|||||||
|
|
||||||
import db from '../../db';
|
import db from '../../db';
|
||||||
import { validateUsername, ValidationError, validatePassword } from '../validation';
|
import { validateUsername, ValidationError, validatePassword } from '../validation';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
function createUser(user) {
|
|
||||||
|
async function createUser(user) {
|
||||||
try {
|
try {
|
||||||
validateUsername(user.username);
|
validateUsername(user.username);
|
||||||
validatePassword(user.password);
|
validatePassword(user.password);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
return Promise.reject({ error: err.message });
|
throw { error: err.message };
|
||||||
} else throw err;
|
} else throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.one(
|
try {
|
||||||
|
return await db.one(
|
||||||
`INSERT
|
`INSERT
|
||||||
INTO users (
|
INTO users (
|
||||||
user_id,
|
user_id,
|
||||||
@ -35,22 +38,24 @@ function createUser(user) {
|
|||||||
user.email,
|
user.email,
|
||||||
user.password
|
user.password
|
||||||
]
|
]
|
||||||
).catch(function (error) {
|
);
|
||||||
console.error('Error:', error)
|
} catch(error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
|
||||||
if (error.detail.indexOf('already exists') !== -1) {
|
if (error.detail.includes('already exists')) {
|
||||||
if (error.detail.indexOf('username') !== -1) {
|
if (error.detail.includes('username')) {
|
||||||
return { error: 'Username already registered' };
|
return { error: 'Username already registered' };
|
||||||
} else if (error.detail.indexOf('email') !== -1) {
|
} else if (error.detail.includes('email')) {
|
||||||
return { error: 'Email already registered' };
|
return { error: 'Email already registered' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { error: 'Database error' }
|
return { error: 'Database error' };
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function authUser(username, password) {
|
async function authUser(username: string, password: string) {
|
||||||
return db.one(
|
try {
|
||||||
|
const user = await db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
user_id,
|
user_id,
|
||||||
(
|
(
|
||||||
@ -63,24 +68,26 @@ function authUser(username, password) {
|
|||||||
username,
|
username,
|
||||||
password
|
password
|
||||||
]
|
]
|
||||||
).then(function (user) {
|
);
|
||||||
|
|
||||||
if (user && user.auth_ok) {
|
if (user && user.auth_ok) {
|
||||||
return { user_id: user.user_id }
|
return { user_id: user.user_id }
|
||||||
} else {
|
} else {
|
||||||
return { error: 'Username or password not recognised' }
|
return { error: 'Username or password not recognised' }
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
} catch(err) {
|
||||||
if (err instanceof errors.QueryResultError) {
|
if (err instanceof errors.QueryResultError) {
|
||||||
console.error(`Authentication failed for user ${username}`);
|
console.error(`Authentication failed for user ${username}`);
|
||||||
return { error: 'Username or password not recognised' };
|
return { error: 'Username or password not recognised' };
|
||||||
}
|
}
|
||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
return { error: 'Database error' };
|
return { error: 'Database error' };
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserById(id) {
|
async function getUserById(id: string) {
|
||||||
return db.one(
|
try {
|
||||||
|
return await db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
username, email, registered, api_key
|
username, email, registered, api_key
|
||||||
FROM
|
FROM
|
||||||
@ -90,13 +97,15 @@ function getUserById(id) {
|
|||||||
`, [
|
`, [
|
||||||
id
|
id
|
||||||
]
|
]
|
||||||
).catch(function (error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error('Error:', error)
|
console.error('Error:', error)
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserByEmail(email: string) {
|
async function getUserByEmail(email: string) {
|
||||||
|
try {
|
||||||
return db.one(
|
return db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
user_id, username, email
|
user_id, username, email
|
||||||
@ -105,13 +114,15 @@ function getUserByEmail(email: string) {
|
|||||||
WHERE
|
WHERE
|
||||||
email = $1
|
email = $1
|
||||||
`, [email]
|
`, [email]
|
||||||
).catch(function(error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNewUserAPIKey(id) {
|
async function getNewUserAPIKey(id: string) {
|
||||||
|
try{
|
||||||
return db.one(
|
return db.one(
|
||||||
`UPDATE
|
`UPDATE
|
||||||
users
|
users
|
||||||
@ -124,14 +135,16 @@ function getNewUserAPIKey(id) {
|
|||||||
`, [
|
`, [
|
||||||
id
|
id
|
||||||
]
|
]
|
||||||
).catch(function (error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error('Error:', error)
|
console.error('Error:', error)
|
||||||
return { error: 'Failed to generate new API key.' };
|
return { error: 'Failed to generate new API key.' };
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function authAPIUser(key) {
|
async function authAPIUser(key: string) {
|
||||||
return db.one(
|
try {
|
||||||
|
return await db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
user_id
|
user_id
|
||||||
FROM
|
FROM
|
||||||
@ -141,14 +154,16 @@ function authAPIUser(key) {
|
|||||||
`, [
|
`, [
|
||||||
key
|
key
|
||||||
]
|
]
|
||||||
).catch(function (error) {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error('Error:', error)
|
console.error('Error:', error)
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUser(id) {
|
async function deleteUser(id: string) {
|
||||||
return db.none(
|
try {
|
||||||
|
return await db.none(
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET
|
SET
|
||||||
email = null,
|
email = null,
|
||||||
@ -159,20 +174,17 @@ function deleteUser(id) {
|
|||||||
deleted_on = now() at time zone 'utc'
|
deleted_on = now() at time zone 'utc'
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
`, [id]
|
`, [id]
|
||||||
).catch((error) => {
|
);
|
||||||
|
} catch(error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
return {error: 'Database error'};
|
return {error: 'Database error'};
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout(session: Express.Session) {
|
function logout(session: Express.Session): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
session.user_id = undefined;
|
session.user_id = undefined;
|
||||||
session.destroy(err => {
|
|
||||||
if (err) return reject(err);
|
return promisify(session.destroy.bind(session))();
|
||||||
return resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -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')
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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} />
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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"
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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" />
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user